From 9b4af087caa982537f01e76ff3237641142dc5b7 Mon Sep 17 00:00:00 2001 From: Santhosh Shanmugam Date: Wed, 23 Nov 2022 13:21:43 +0000 Subject: [PATCH] Added support for custom content type handling Updated payload log format Removed references to api_keys Added function to format headers. --- docs/conf.py | 1 + docs/response.rst | 2 +- .../enforcedefaults_aiohttp/README.rst | 16 + .../enforcedefaults-api.yaml | 39 + .../enforcedefaults.py | 56 + .../openapi3/helloworld_aiohttp/README.rst | 11 + examples/openapi3/helloworld_aiohttp/hello.py | 14 + .../openapi/helloworld-api.yaml | 30 + .../openapi3/reverseproxy_aiohttp/README.rst | 58 + examples/openapi3/reverseproxy_aiohttp/app.py | 82 + .../openapi3/reverseproxy_aiohttp/nginx.conf | 38 + .../reverseproxy_aiohttp/openapi.yaml | 18 + examples/swagger2/basicauth/README.rst | 14 + examples/swagger2/basicauth/app.py | 26 + examples/swagger2/basicauth/swagger.yaml | 22 + examples/swagger2/enforcedefaults/README.rst | 18 + .../enforcedefaults/enforcedefaults-api.yaml | 42 + .../enforcedefaults/enforcedefaults.py | 46 + examples/swagger2/helloworld/README.rst | 11 + examples/swagger2/helloworld/hello.py | 12 + .../helloworld/swagger/helloworld-api.yaml | 29 + examples/swagger2/mock/swagger.yaml | 116 + examples/swagger2/oauth2/README.rst | 4 +- .../oauth2_local_tokeninfo/README.rst | 4 +- examples/swagger2/restyresolver/README.rst | 11 + .../swagger2/restyresolver/api/__init__.py | 1 + examples/swagger2/restyresolver/api/pets.py | 43 + .../swagger2/restyresolver/resty-api.yaml | 80 + examples/swagger2/restyresolver/resty.py | 14 + examples/swagger2/sqlalchemy/README.rst | 15 + examples/swagger2/sqlalchemy/app.py | 65 + examples/swagger2/sqlalchemy/orm.py | 32 + examples/swagger2/sqlalchemy/requirements.txt | 3 + examples/swagger2/sqlalchemy/swagger.yaml | 111 + firetail/__init__.py | 93 +- firetail/__main__.py | 14 +- firetail/apis/__init__.py | 32 +- firetail/apis/abstract.py | 950 +++-- firetail/apis/aiohttp_api.py | 450 +++ firetail/apis/flask_api.py | 536 ++- firetail/apis/flask_utils.py | 166 +- firetail/apps/__init__.py | 12 +- firetail/apps/abstract.py | 525 ++- firetail/apps/aiohttp_app.py | 99 + firetail/apps/flask_app.py | 391 +- firetail/auditor.py | 53 +- firetail/cli.py | 422 ++- firetail/decorators/__init__.py | 5 +- firetail/decorators/decorator.py | 142 +- firetail/decorators/metrics.py | 122 +- firetail/decorators/parameter.py | 241 +- firetail/decorators/produces.py | 104 +- firetail/decorators/response.py | 241 +- firetail/decorators/uri_parsing.py | 695 ++-- firetail/decorators/validation.py | 818 ++-- firetail/exceptions.py | 294 +- firetail/handlers.py | 424 ++- firetail/http_facts.py | 38 +- firetail/json_schema.py | 320 +- firetail/jsonifier.py | 141 +- firetail/lifecycle.py | 110 +- firetail/mock.py | 102 +- firetail/operations/__init__.py | 32 +- firetail/operations/abstract.py | 916 ++--- firetail/operations/compat.py | 10 +- firetail/operations/openapi.py | 820 ++-- firetail/operations/secure.py | 371 +- firetail/operations/swagger2.py | 641 ++-- firetail/options.py | 312 +- firetail/problem.py | 99 +- firetail/resolver.py | 500 +-- firetail/resources/schemas/v2.0/schema.json | 3180 ++++++++-------- firetail/resources/schemas/v3.0/schema.json | 3306 ++++++++--------- firetail/security/__init__.py | 41 +- .../aiohttp_security_handler_factory.py | 39 + .../async_security_handler_factory.py | 210 +- .../flask_security_handler_factory.py | 80 +- firetail/security/security_handler_factory.py | 887 +++-- firetail/sender.py | 9 +- firetail/spec.py | 598 +-- firetail/utils.py | 524 +-- setup.py | 28 +- tests/aiohttp/test_aiohttp_api_secure.py | 175 + tests/aiohttp/test_aiohttp_app.py | 159 + tests/aiohttp/test_aiohttp_datetime.py | 49 + tests/aiohttp/test_aiohttp_errors.py | 136 + tests/aiohttp/test_aiohttp_multipart.py | 117 + tests/aiohttp/test_aiohttp_reverse_proxy.py | 128 + tests/aiohttp/test_aiohttp_simple_api.py | 376 ++ tests/aiohttp/test_get_response.py | 171 + tests/api/test_bootstrap.py | 661 ++-- tests/api/test_errors.py | 176 +- tests/api/test_headers.py | 106 +- tests/api/test_parameters.py | 1077 +++--- tests/api/test_responses.py | 829 +++-- tests/api/test_schema.py | 478 +-- tests/api/test_secure_api.py | 286 +- tests/api/test_unordered_definition.py | 18 +- tests/conftest.py | 494 ++- tests/decorators/test_parameter.py | 60 +- tests/decorators/test_security.py | 504 ++- tests/decorators/test_uri_parsing.py | 337 +- tests/decorators/test_validation.py | 405 +- tests/fakeapi/__init__.py | 10 +- tests/fakeapi/aiohttp_handlers.py | 154 + tests/fakeapi/aiohttp_handlers_async_def.py | 5 + tests/fakeapi/auth.py | 59 +- tests/fakeapi/example_method_view.py | 30 +- tests/fakeapi/foo_bar.py | 10 +- tests/fakeapi/hello/__init__.py | 1275 ++++--- tests/fakeapi/hello/world.py | 10 +- tests/fakeapi/module_with_error.py | 12 +- tests/fakeapi/module_with_exception.py | 12 +- tests/fakeapi/snake_case.py | 88 +- tests/fixtures/aiohttp/datetime_support.yaml | 60 + .../aiohttp/openapi_empty_base_path.yaml | 28 + tests/fixtures/aiohttp/openapi_multipart.yaml | 132 + tests/fixtures/aiohttp/openapi_secure.yaml | 114 + tests/fixtures/aiohttp/openapi_simple.yaml | 35 + .../aiohttp/swagger_empty_base_path.yaml | 29 + tests/fixtures/aiohttp/swagger_secure.yaml | 54 + tests/fixtures/aiohttp/swagger_simple.yaml | 203 + .../aiohttp/swagger_simple_async_def.yaml | 21 + tests/fixtures/bad_operations/openapi.yaml | 68 +- tests/fixtures/bad_operations/swagger.yaml | 62 +- tests/fixtures/bad_specs/openapi.yaml | 46 +- tests/fixtures/bad_specs/swagger.yaml | 46 +- tests/fixtures/datetime_support/openapi.yaml | 120 +- tests/fixtures/datetime_support/swagger.yaml | 104 +- .../fixtures/default_param_error/openapi.yaml | 42 +- .../fixtures/default_param_error/swagger.yaml | 44 +- tests/fixtures/different_schemas/openapi.yaml | 608 +-- tests/fixtures/different_schemas/swagger.yaml | 622 ++-- tests/fixtures/invalid_schema/swagger.yaml | 24 +- tests/fixtures/json_validation/openapi.yaml | 242 +- tests/fixtures/json_validation/swagger.yaml | 164 +- .../missing_implementation/openapi.yaml | 28 +- .../missing_implementation/swagger.yaml | 32 +- tests/fixtures/missing_op_id/openapi.yaml | 32 +- tests/fixtures/missing_op_id/swagger.yaml | 34 +- .../module_does_not_exist/openapi.yaml | 38 +- .../module_does_not_exist/swagger.yaml | 42 +- .../module_not_implemented/openapi.yaml | 34 +- .../module_not_implemented/swagger.yaml | 34 +- tests/fixtures/op_error_api/openapi.yaml | 34 +- tests/fixtures/op_error_api/swagger.yaml | 34 +- tests/fixtures/problem/openapi.yaml | 210 +- tests/fixtures/problem/swagger.yaml | 238 +- tests/fixtures/secure_api/openapi.yaml | 78 +- tests/fixtures/secure_api/swagger.yaml | 76 +- tests/fixtures/secure_endpoint/openapi.yaml | 342 +- tests/fixtures/secure_endpoint/swagger.yaml | 363 +- tests/fixtures/simple/basepath-slash.yaml | 50 +- tests/fixtures/simple/openapi.yaml | 2577 ++++++------- tests/fixtures/simple/swagger.yaml | 2124 +++++------ tests/fixtures/snake_case/openapi.yaml | 436 +-- tests/fixtures/snake_case/swagger.yaml | 378 +- .../unordered_definition/openapi.yaml | 62 +- .../unordered_definition/swagger.yaml | 60 +- .../user_module_loading_error/openapi.yaml | 34 +- .../user_module_loading_error/swagger.yaml | 34 +- tests/test_api.py | 272 +- tests/test_app.py | 38 +- tests/test_cli.py | 581 +-- tests/test_flask_encoder.py | 180 +- tests/test_flask_utils.py | 53 +- tests/test_json_validation.py | 202 +- tests/test_metrics.py | 47 +- tests/test_mock.py | 792 ++-- tests/test_mock3.py | 363 +- tests/test_operation2.py | 1320 ++++--- tests/test_references.py | 228 +- tests/test_resolver.py | 673 ++-- tests/test_resolver3.py | 519 ++- tests/test_resolver_methodview.py | 361 +- tests/test_utils.py | 125 +- tests/test_validation.py | 197 +- tox.ini | 12 +- 178 files changed, 24848 insertions(+), 21021 deletions(-) create mode 100644 examples/openapi3/enforcedefaults_aiohttp/README.rst create mode 100644 examples/openapi3/enforcedefaults_aiohttp/enforcedefaults-api.yaml create mode 100755 examples/openapi3/enforcedefaults_aiohttp/enforcedefaults.py create mode 100644 examples/openapi3/helloworld_aiohttp/README.rst create mode 100755 examples/openapi3/helloworld_aiohttp/hello.py create mode 100644 examples/openapi3/helloworld_aiohttp/openapi/helloworld-api.yaml create mode 100644 examples/openapi3/reverseproxy_aiohttp/README.rst create mode 100755 examples/openapi3/reverseproxy_aiohttp/app.py create mode 100644 examples/openapi3/reverseproxy_aiohttp/nginx.conf create mode 100644 examples/openapi3/reverseproxy_aiohttp/openapi.yaml create mode 100644 examples/swagger2/basicauth/README.rst create mode 100755 examples/swagger2/basicauth/app.py create mode 100644 examples/swagger2/basicauth/swagger.yaml create mode 100644 examples/swagger2/enforcedefaults/README.rst create mode 100644 examples/swagger2/enforcedefaults/enforcedefaults-api.yaml create mode 100755 examples/swagger2/enforcedefaults/enforcedefaults.py create mode 100644 examples/swagger2/helloworld/README.rst create mode 100755 examples/swagger2/helloworld/hello.py create mode 100644 examples/swagger2/helloworld/swagger/helloworld-api.yaml create mode 100644 examples/swagger2/mock/swagger.yaml create mode 100644 examples/swagger2/restyresolver/README.rst create mode 100644 examples/swagger2/restyresolver/api/__init__.py create mode 100644 examples/swagger2/restyresolver/api/pets.py create mode 100644 examples/swagger2/restyresolver/resty-api.yaml create mode 100755 examples/swagger2/restyresolver/resty.py create mode 100644 examples/swagger2/sqlalchemy/README.rst create mode 100755 examples/swagger2/sqlalchemy/app.py create mode 100644 examples/swagger2/sqlalchemy/orm.py create mode 100644 examples/swagger2/sqlalchemy/requirements.txt create mode 100644 examples/swagger2/sqlalchemy/swagger.yaml mode change 100644 => 100755 firetail/__init__.py create mode 100644 firetail/apis/aiohttp_api.py create mode 100644 firetail/apps/aiohttp_app.py create mode 100644 firetail/security/aiohttp_security_handler_factory.py create mode 100644 tests/aiohttp/test_aiohttp_api_secure.py create mode 100644 tests/aiohttp/test_aiohttp_app.py create mode 100644 tests/aiohttp/test_aiohttp_datetime.py create mode 100644 tests/aiohttp/test_aiohttp_errors.py create mode 100644 tests/aiohttp/test_aiohttp_multipart.py create mode 100644 tests/aiohttp/test_aiohttp_reverse_proxy.py create mode 100644 tests/aiohttp/test_aiohttp_simple_api.py create mode 100644 tests/aiohttp/test_get_response.py create mode 100755 tests/fakeapi/aiohttp_handlers.py create mode 100644 tests/fakeapi/aiohttp_handlers_async_def.py mode change 100644 => 100755 tests/fakeapi/foo_bar.py mode change 100644 => 100755 tests/fakeapi/snake_case.py create mode 100644 tests/fixtures/aiohttp/datetime_support.yaml create mode 100644 tests/fixtures/aiohttp/openapi_empty_base_path.yaml create mode 100644 tests/fixtures/aiohttp/openapi_multipart.yaml create mode 100644 tests/fixtures/aiohttp/openapi_secure.yaml create mode 100644 tests/fixtures/aiohttp/openapi_simple.yaml create mode 100644 tests/fixtures/aiohttp/swagger_empty_base_path.yaml create mode 100644 tests/fixtures/aiohttp/swagger_secure.yaml create mode 100644 tests/fixtures/aiohttp/swagger_simple.yaml create mode 100644 tests/fixtures/aiohttp/swagger_simple_async_def.yaml diff --git a/docs/conf.py b/docs/conf.py index 3353b31..3201e4d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -28,6 +28,7 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. import firetail + extensions = ['autoapi.extension'] autoapi_type = 'python' diff --git a/docs/response.rst b/docs/response.rst index 08c9d4d..8a0ba39 100644 --- a/docs/response.rst +++ b/docs/response.rst @@ -18,7 +18,7 @@ Customizing JSON encoder Firetail allows you to customize the `JSONEncoder` class in the Flask app instance `json_encoder` (`firetail.App:app`). If you wanna reuse the Firetail's date-time serialization, inherit your custom encoder from -`firetail.apps.flask_app.FlaskJSONEncoder`. +`firetail.apps.flask_app.FlaskJSONProvider`. For more information on the `JSONEncoder`, see the `Flask documentation`_. diff --git a/examples/openapi3/enforcedefaults_aiohttp/README.rst b/examples/openapi3/enforcedefaults_aiohttp/README.rst new file mode 100644 index 0000000..9063161 --- /dev/null +++ b/examples/openapi3/enforcedefaults_aiohttp/README.rst @@ -0,0 +1,16 @@ +======================== +Custom Validator Example +======================== + +In this example we fill-in non-provided properties with their defaults. +Validator code is based on example from `python-jsonschema docs`_. + +Running: + +.. code-block:: bash + + $ ./enforcedefaults.py + +Now open your browser and go to http://localhost:8080/v1/ui/ to see the Swagger +UI. If you send a ``POST`` request with empty body ``{}``, you should receive +echo with defaults filled-in. diff --git a/examples/openapi3/enforcedefaults_aiohttp/enforcedefaults-api.yaml b/examples/openapi3/enforcedefaults_aiohttp/enforcedefaults-api.yaml new file mode 100644 index 0000000..02d00eb --- /dev/null +++ b/examples/openapi3/enforcedefaults_aiohttp/enforcedefaults-api.yaml @@ -0,0 +1,39 @@ +openapi: '3.0.0' +info: + version: '1' + title: Custom Validator Example +servers: + - url: http://localhost:8080/{basePath} + variables: + basePath: + default: api +paths: + /echo: + post: + description: Echo passed data + operationId: enforcedefaults.echo + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Data' + responses: + '200': + description: Data with defaults filled in by validator + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + schemas: + Data: + type: object + properties: + foo: + type: string + default: foo + Error: + type: string diff --git a/examples/openapi3/enforcedefaults_aiohttp/enforcedefaults.py b/examples/openapi3/enforcedefaults_aiohttp/enforcedefaults.py new file mode 100755 index 0000000..6d33a6f --- /dev/null +++ b/examples/openapi3/enforcedefaults_aiohttp/enforcedefaults.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 + +import firetail +import jsonschema +import six +from firetail.decorators.validation import RequestBodyValidator +from firetail.json_schema import Draft4RequestValidator + + +async def echo(body): + return body + + +# via https://python-jsonschema.readthedocs.io/ +def extend_with_set_default(validator_class): + validate_properties = validator_class.VALIDATORS['properties'] + + def set_defaults(validator, properties, instance, schema): + for property, subschema in six.iteritems(properties): + if 'default' in subschema: + instance.setdefault(property, subschema['default']) + + for error in validate_properties( + validator, properties, instance, schema): + yield error + + return jsonschema.validators.extend( + validator_class, {'properties': set_defaults}) + +DefaultsEnforcingDraft4Validator = extend_with_set_default(Draft4RequestValidator) + + +class DefaultsEnforcingRequestBodyValidator(RequestBodyValidator): + def __init__(self, *args, **kwargs): + super(DefaultsEnforcingRequestBodyValidator, self).__init__( + *args, validator=DefaultsEnforcingDraft4Validator, **kwargs) + + +validator_map = { + 'body': DefaultsEnforcingRequestBodyValidator +} + + +if __name__ == '__main__': + app = firetail.AioHttpApp( + __name__, + port=8080, + specification_dir='.', + options={'swagger_ui': True} + ) + app.add_api( + 'enforcedefaults-api.yaml', + arguments={'title': 'Hello World Example'}, + validator_map=validator_map, + ) + app.run() diff --git a/examples/openapi3/helloworld_aiohttp/README.rst b/examples/openapi3/helloworld_aiohttp/README.rst new file mode 100644 index 0000000..b154571 --- /dev/null +++ b/examples/openapi3/helloworld_aiohttp/README.rst @@ -0,0 +1,11 @@ +=================== +Hello World Example +=================== + +Running: + +.. code-block:: bash + + $ ./hello.py + +Now open your browser and go to http://localhost:9090/v1.0/ui/ to see the Swagger UI. diff --git a/examples/openapi3/helloworld_aiohttp/hello.py b/examples/openapi3/helloworld_aiohttp/hello.py new file mode 100755 index 0000000..2eb5e58 --- /dev/null +++ b/examples/openapi3/helloworld_aiohttp/hello.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 + +import firetail +from aiohttp import web + + +async def post_greeting(name): + return web.Response(text=f'Hello {name}') + + +if __name__ == '__main__': + app = firetail.AioHttpApp(__name__, port=9090, specification_dir='openapi/') + app.add_api('helloworld-api.yaml', arguments={'title': 'Hello World Example'}) + app.run() diff --git a/examples/openapi3/helloworld_aiohttp/openapi/helloworld-api.yaml b/examples/openapi3/helloworld_aiohttp/openapi/helloworld-api.yaml new file mode 100644 index 0000000..214dd15 --- /dev/null +++ b/examples/openapi3/helloworld_aiohttp/openapi/helloworld-api.yaml @@ -0,0 +1,30 @@ +openapi: "3.0.0" + +info: + title: Hello World + version: "1.0" +servers: + - url: http://localhost:9090/v1.0 + +paths: + /greeting/{name}: + post: + summary: Generate greeting + description: Generates a greeting message. + operationId: hello.post_greeting + responses: + 200: + description: greeting response + content: + text/plain: + schema: + type: string + example: "hello dave!" + parameters: + - name: name + in: path + description: Name of the person to greet. + required: true + schema: + type: string + example: "dave" diff --git a/examples/openapi3/reverseproxy_aiohttp/README.rst b/examples/openapi3/reverseproxy_aiohttp/README.rst new file mode 100644 index 0000000..3ebee3f --- /dev/null +++ b/examples/openapi3/reverseproxy_aiohttp/README.rst @@ -0,0 +1,58 @@ +===================== +Reverse Proxy Example +===================== + +This example demonstrates how to run a firetail application behind a path-altering reverse proxy. + +You can either set the path in your app, or set the ``X-Forwarded-Path`` header. + +Running: + +.. code-block:: bash + + $ sudo pip3 install --upgrade firetail[swagger-ui] aiohttp-remotes + $ ./app.py + +Now open your browser and go to http://localhost:8080/reverse_proxied/ui/ to see the Swagger UI. + + +You can also use the ``X-Forwarded-Path`` header to modify the reverse proxy path. +For example: + +.. code-block:: bash + + curl -H "X-Forwarded-Path: /banana/" http://localhost:8080/openapi.json + + { + "servers" : [ + { + "url" : "banana" + } + ], + "paths" : { + "/hello" : { + "get" : { + "responses" : { + "200" : { + "description" : "hello", + "content" : { + "text/plain" : { + "schema" : { + "type" : "string" + } + } + } + } + }, + "operationId" : "app.hello", + "summary" : "say hi" + } + } + }, + "openapi" : "3.0.0", + "info" : { + "version" : "1.0", + "title" : "Path-Altering Reverse Proxy Example" + } + } + diff --git a/examples/openapi3/reverseproxy_aiohttp/app.py b/examples/openapi3/reverseproxy_aiohttp/app.py new file mode 100755 index 0000000..4fb9a19 --- /dev/null +++ b/examples/openapi3/reverseproxy_aiohttp/app.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +''' +example of aiohttp firetail running behind a path-altering reverse-proxy +''' + +import json +import logging + +import firetail +from aiohttp import web +from aiohttp_remotes.exceptions import RemoteError, TooManyHeaders +from aiohttp_remotes.x_forwarded import XForwardedBase +from yarl import URL + +X_FORWARDED_PATH = "X-Forwarded-Path" + + +class XPathForwarded(XForwardedBase): + + def __init__(self, num=1): + self._num = num + + def get_forwarded_path(self, headers): + forwarded_host = headers.getall(X_FORWARDED_PATH, []) + if len(forwarded_host) > 1: + raise TooManyHeaders(X_FORWARDED_PATH) + return forwarded_host[0] if forwarded_host else None + + @web.middleware + async def middleware(self, request, handler): + logging.warning( + "this demo is not secure by default!! " + "You'll want to make sure these headers are coming from your proxy, " + "and not directly from users on the web!" + ) + try: + overrides = {} + headers = request.headers + + forwarded_for = self.get_forwarded_for(headers) + if forwarded_for: + overrides['remote'] = str(forwarded_for[-self._num]) + + proto = self.get_forwarded_proto(headers) + if proto: + overrides['scheme'] = proto[-self._num] + + host = self.get_forwarded_host(headers) + if host is not None: + overrides['host'] = host + + prefix = self.get_forwarded_path(headers) + if prefix is not None: + prefix = '/' + prefix.strip('/') + '/' + request_path = URL(request.path.lstrip('/')) + overrides['rel_url'] = URL(prefix).join(request_path) + + request = request.clone(**overrides) + + return await handler(request) + except RemoteError as exc: + exc.log(request) + await self.raise_error(request) + + +def hello(request): + ret = { + "host": request.host, + "scheme": request.scheme, + "path": request.path, + "_href": str(request.url) + } + return web.Response(text=json.dumps(ret), status=200) + + +if __name__ == '__main__': + app = firetail.AioHttpApp(__name__) + app.add_api('openapi.yaml', pass_context_arg_name='request') + aio = app.app + reverse_proxied = XPathForwarded() + aio.middlewares.append(reverse_proxied.middleware) + app.run(port=8080) diff --git a/examples/openapi3/reverseproxy_aiohttp/nginx.conf b/examples/openapi3/reverseproxy_aiohttp/nginx.conf new file mode 100644 index 0000000..98ba4cd --- /dev/null +++ b/examples/openapi3/reverseproxy_aiohttp/nginx.conf @@ -0,0 +1,38 @@ +worker_processes 1; +error_log stderr; +daemon off; +pid nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + + keepalive_timeout 65; + + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; + ssl_prefer_server_ciphers on; + access_log access.log; + server { + + listen localhost:9000; + + location /reverse_proxied/ { + # Define the location of the proxy server to send the request to + proxy_pass http://localhost:8080/; + # Add prefix header + proxy_set_header X-Forwarded-Path /reverse_proxied/; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host:$server_port; + proxy_set_header X-Forwarded-Server $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Proto http; + } + + } +} diff --git a/examples/openapi3/reverseproxy_aiohttp/openapi.yaml b/examples/openapi3/reverseproxy_aiohttp/openapi.yaml new file mode 100644 index 0000000..90d0625 --- /dev/null +++ b/examples/openapi3/reverseproxy_aiohttp/openapi.yaml @@ -0,0 +1,18 @@ +openapi: 3.0.0 +info: + title: Path-Altering Reverse Proxy Example + version: '1.0' +servers: + - url: /api +paths: + /hello: + get: + summary: say hi + operationId: app.hello + responses: + '200': + description: hello + content: + text/plain: + schema: + type: string diff --git a/examples/swagger2/basicauth/README.rst b/examples/swagger2/basicauth/README.rst new file mode 100644 index 0000000..0548002 --- /dev/null +++ b/examples/swagger2/basicauth/README.rst @@ -0,0 +1,14 @@ +======================= +HTTP Basic Auth Example +======================= + +Running: + +.. code-block:: bash + + $ sudo pip3 install --upgrade firetail[swagger-ui] # install Firetail from PyPI + $ ./app.py + +Now open your browser and go to http://localhost:8080/ui/ to see the Swagger UI. + +The hardcoded credentials are ``admin:secret`` and ``foo:bar``. diff --git a/examples/swagger2/basicauth/app.py b/examples/swagger2/basicauth/app.py new file mode 100755 index 0000000..b77c4f0 --- /dev/null +++ b/examples/swagger2/basicauth/app.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +''' +Basic example of a resource server +''' + +import firetail + +PASSWD = { + 'admin': 'secret', + 'foo': 'bar' +} + +def basic_auth(username, password): + if PASSWD.get(username) == password: + return {'sub': username} + # optional: raise exception for custom error response + return None + +def get_secret(user) -> str: + return f"You are {user} and the secret is 'wbevuec'" + + +if __name__ == '__main__': + app = firetail.FlaskApp(__name__) + app.add_api('swagger.yaml') + app.run(port=8080) diff --git a/examples/swagger2/basicauth/swagger.yaml b/examples/swagger2/basicauth/swagger.yaml new file mode 100644 index 0000000..fe0d0fd --- /dev/null +++ b/examples/swagger2/basicauth/swagger.yaml @@ -0,0 +1,22 @@ +swagger: "2.0" + +info: + title: Basic Auth Example + version: "1.0" + +paths: + /secret: + get: + summary: Return secret string + operationId: app.get_secret + responses: + 200: + description: secret response + schema: + type: string + security: + - basic: [] +securityDefinitions: + basic: + type: basic + x-basicInfoFunc: app.basic_auth diff --git a/examples/swagger2/enforcedefaults/README.rst b/examples/swagger2/enforcedefaults/README.rst new file mode 100644 index 0000000..47f8417 --- /dev/null +++ b/examples/swagger2/enforcedefaults/README.rst @@ -0,0 +1,18 @@ +======================== +Custom Validator Example +======================== + +In this example we fill-in non-provided properties with their defaults. +Validator code is based on example from `python-jsonschema docs`_. + +Running: + +.. code-block:: bash + + $ ./enforcedefaults.py + +Now open your browser and go to http://localhost:8080/v1/ui/ to see the Swagger +UI. If you send a ``POST`` request with empty body ``{}``, you should receive +echo with defaults filled-in. + +.. _python-jsonschema docs: https://python-jsonschema.readthedocs.io/en/latest/faq/#why-doesn-t-my-schema-that-has-a-default-property-actually-set-the-default-on-my-instance diff --git a/examples/swagger2/enforcedefaults/enforcedefaults-api.yaml b/examples/swagger2/enforcedefaults/enforcedefaults-api.yaml new file mode 100644 index 0000000..dbcd2b8 --- /dev/null +++ b/examples/swagger2/enforcedefaults/enforcedefaults-api.yaml @@ -0,0 +1,42 @@ +swagger: '2.0' +info: + version: '1' + title: Custom Validator Example +basePath: '/v1' +consumes: + - application/json +produces: + - application/json +paths: + /echo: + post: + description: Echo passed data + operationId: enforcedefaults.echo + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/Data' + responses: + '200': + description: Data with defaults filled in by validator + schema: + $ref: '#/definitions/Data' + default: + description: Unexpected error + schema: + $ref: '#/definitions/Error' +definitions: + Data: + type: object + properties: + outer-object: + type: object + default: {} + properties: + inner-object: + type: string + default: foo + Error: + type: string diff --git a/examples/swagger2/enforcedefaults/enforcedefaults.py b/examples/swagger2/enforcedefaults/enforcedefaults.py new file mode 100755 index 0000000..7ba8ddf --- /dev/null +++ b/examples/swagger2/enforcedefaults/enforcedefaults.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 + +import firetail +import jsonschema +from firetail.decorators.validation import RequestBodyValidator +from firetail.json_schema import Draft4RequestValidator + + +def echo(data): + return data + + +# via https://python-jsonschema.readthedocs.io/ +def extend_with_set_default(validator_class): + validate_properties = validator_class.VALIDATORS['properties'] + + def set_defaults(validator, properties, instance, schema): + for property, subschema in properties.items(): + if 'default' in subschema: + instance.setdefault(property, subschema['default']) + + yield from validate_properties( + validator, properties, instance, schema) + + return jsonschema.validators.extend( + validator_class, {'properties': set_defaults}) + +DefaultsEnforcingDraft4Validator = extend_with_set_default(Draft4RequestValidator) + + +class DefaultsEnforcingRequestBodyValidator(RequestBodyValidator): + def __init__(self, *args, **kwargs): + super().__init__( + *args, validator=DefaultsEnforcingDraft4Validator, **kwargs) + + +validator_map = { + 'body': DefaultsEnforcingRequestBodyValidator +} + + +if __name__ == '__main__': + app = firetail.FlaskApp( + __name__, port=8080, specification_dir='.') + app.add_api('enforcedefaults-api.yaml', validator_map=validator_map) + app.run() diff --git a/examples/swagger2/helloworld/README.rst b/examples/swagger2/helloworld/README.rst new file mode 100644 index 0000000..b154571 --- /dev/null +++ b/examples/swagger2/helloworld/README.rst @@ -0,0 +1,11 @@ +=================== +Hello World Example +=================== + +Running: + +.. code-block:: bash + + $ ./hello.py + +Now open your browser and go to http://localhost:9090/v1.0/ui/ to see the Swagger UI. diff --git a/examples/swagger2/helloworld/hello.py b/examples/swagger2/helloworld/hello.py new file mode 100755 index 0000000..8e1793e --- /dev/null +++ b/examples/swagger2/helloworld/hello.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 + +import firetail + + +def post_greeting(name: str) -> str: + return f'Hello {name}' + +if __name__ == '__main__': + app = firetail.FlaskApp(__name__, port=9090, specification_dir='swagger/') + app.add_api('helloworld-api.yaml', arguments={'title': 'Hello World Example'}) + app.run() diff --git a/examples/swagger2/helloworld/swagger/helloworld-api.yaml b/examples/swagger2/helloworld/swagger/helloworld-api.yaml new file mode 100644 index 0000000..f75cb74 --- /dev/null +++ b/examples/swagger2/helloworld/swagger/helloworld-api.yaml @@ -0,0 +1,29 @@ +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /v1.0 + +paths: + /greeting/{name}: + post: + summary: Generate greeting + description: Generates a greeting message. + operationId: hello.post_greeting + produces: + - text/plain; + responses: + 200: + description: greeting response + schema: + type: string + examples: + "text/plain": "Hello John" + parameters: + - name: name + in: path + description: Name of the person to greet. + required: true + type: string diff --git a/examples/swagger2/mock/swagger.yaml b/examples/swagger2/mock/swagger.yaml new file mode 100644 index 0000000..013c9f4 --- /dev/null +++ b/examples/swagger2/mock/swagger.yaml @@ -0,0 +1,116 @@ +swagger: '2.0' +info: + title: Pet Shop Example API + version: "0.1" +consumes: + - application/json +produces: + - application/json +paths: + /pets: + get: + tags: [Pets] + summary: Get all pets + parameters: + - name: animal_type + in: query + type: string + pattern: "^[a-zA-Z0-9]*$" + - name: limit + in: query + type: integer + minimum: 0 + default: 100 + responses: + 200: + description: Return pets + schema: + type: array + items: + $ref: '#/definitions/Pet' + examples: + application/json: + - id: 1 + name: Susie + animal_type: cat + + /pets/{pet_id}: + get: + tags: [Pets] + summary: Get a single pet + parameters: + - $ref: '#/parameters/pet_id' + responses: + 200: + description: Return pet + schema: + $ref: '#/definitions/Pet' + 404: + description: Pet does not exist + put: + tags: [Pets] + summary: Create or update a pet + parameters: + - $ref: '#/parameters/pet_id' + - name: pet + in: body + schema: + $ref: '#/definitions/Pet' + responses: + 200: + description: Pet updated + 201: + description: New pet created + delete: + tags: [Pets] + summary: Remove a pet + parameters: + - $ref: '#/parameters/pet_id' + responses: + 204: + description: Pet was deleted + 404: + description: Pet does not exist + + +parameters: + pet_id: + name: pet_id + description: Pet's Unique identifier + in: path + type: string + required: true + pattern: "^[a-zA-Z0-9-]+$" + +definitions: + Pet: + type: object + required: + - name + - animal_type + properties: + id: + type: string + description: Unique identifier + example: "123" + readOnly: true + name: + type: string + description: Pet's name + example: "Susie" + minLength: 1 + maxLength: 100 + animal_type: + type: string + description: Kind of animal + example: "cat" + minLength: 1 + tags: + type: object + description: Custom tags + created: + type: string + format: date-time + description: Creation time + example: "2015-07-07T15:49:51.230+02:00" + readOnly: true diff --git a/examples/swagger2/oauth2/README.rst b/examples/swagger2/oauth2/README.rst index 7ab5851..66bd174 100644 --- a/examples/swagger2/oauth2/README.rst +++ b/examples/swagger2/oauth2/README.rst @@ -2,14 +2,14 @@ OAuth2 Example ============== -This example demonstrates how to implement a resource server with Connexion. +This example demonstrates how to implement a resource server with Firetail. The app will lookup OAuth2 Bearer tokens with the given token info function. Running: .. code-block:: bash - $ sudo pip3 install --upgrade connexion # install Connexion from PyPI + $ sudo pip3 install --upgrade firetail # install firetail from PyPI $ ./mock_tokeninfo.py & # start mock in background $ ./app.py diff --git a/examples/swagger2/oauth2_local_tokeninfo/README.rst b/examples/swagger2/oauth2_local_tokeninfo/README.rst index d1f4bcb..d2e872e 100644 --- a/examples/swagger2/oauth2_local_tokeninfo/README.rst +++ b/examples/swagger2/oauth2_local_tokeninfo/README.rst @@ -2,14 +2,14 @@ OAuth2 Local Validation Example =============================== -This example demonstrates how to implement a resource server with Connexion. +This example demonstrates how to implement a resource server with firetail. The app will lookup OAuth2 Bearer tokens in a static map. Running: .. code-block:: bash - $ sudo pip3 install --upgrade connexion # install Connexion from PyPI + $ sudo pip3 install --upgrade firetail # install firetail from PyPI $ ./app.py Now open your browser and go to http://localhost:8080/ui/ to see the Swagger UI. diff --git a/examples/swagger2/restyresolver/README.rst b/examples/swagger2/restyresolver/README.rst new file mode 100644 index 0000000..b0f0662 --- /dev/null +++ b/examples/swagger2/restyresolver/README.rst @@ -0,0 +1,11 @@ +===================== +RestyResolver Example +===================== + +Running: + +.. code-block:: bash + + $ ./resty.py + +Now open your browser and go to http://localhost:9090/v1.0/ui/ to see the Swagger UI. diff --git a/examples/swagger2/restyresolver/api/__init__.py b/examples/swagger2/restyresolver/api/__init__.py new file mode 100644 index 0000000..6a0ecd6 --- /dev/null +++ b/examples/swagger2/restyresolver/api/__init__.py @@ -0,0 +1 @@ +import api.pets # noqa diff --git a/examples/swagger2/restyresolver/api/pets.py b/examples/swagger2/restyresolver/api/pets.py new file mode 100644 index 0000000..d520e07 --- /dev/null +++ b/examples/swagger2/restyresolver/api/pets.py @@ -0,0 +1,43 @@ +import datetime + +from firetail import NoContent + +pets = {} + + +def post(pet): + count = len(pets) + pet['id'] = count + 1 + pet['registered'] = datetime.datetime.now() + pets[pet['id']] = pet + return pet, 201 + + +def put(id, pet): + id = int(id) + if pets.get(id) is None: + return NoContent, 404 + pets[id] = pet + + return pets[id] + + +def delete(id): + id = int(id) + if pets.get(id) is None: + return NoContent, 404 + del pets[id] + return NoContent, 204 + + +def get(id): + id = int(id) + if pets.get(id) is None: + return NoContent, 404 + + return pets[id] + + +def search(): + # NOTE: we need to wrap it with list for Python 3 as dict_values is not JSON serializable + return list(pets.values()) diff --git a/examples/swagger2/restyresolver/resty-api.yaml b/examples/swagger2/restyresolver/resty-api.yaml new file mode 100644 index 0000000..7911bd9 --- /dev/null +++ b/examples/swagger2/restyresolver/resty-api.yaml @@ -0,0 +1,80 @@ +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /v1.0 + +paths: + /pets: + get: + responses: + '200': + description: 'Fetch a list of pets' + schema: + type: array + items: + $ref: '#/definitions/Pet' + post: + parameters: + - in: body + name: pet + required: true + schema: + $ref: '#/definitions/PetRegistration' + responses: + '201': + description: 'Register a new pet' + + '/pets/{id}': + put: + parameters: + - in: path + name: id + required: true + type: integer + - in: body + name: pet + required: true + schema: + $ref: '#/definitions/Pet' + responses: + '200': + description: 'Update a pet by ID' + delete: + parameters: + - in: path + name: id + required: true + type: integer + responses: + '204': + description: 'Delete a pet by ID' + get: + parameters: + - in: path + name: id + required: true + type: integer + responses: + '200': + description: 'Fetch a pet by ID' + schema: + $ref: '#/definitions/Pet' + +definitions: + PetRegistration: + type: object + properties: + name: { type: string } + Pet: + type: object + properties: + id: + type: integer + format: int64 + name: { type: string } + registered: + type: string + format: date-time \ No newline at end of file diff --git a/examples/swagger2/restyresolver/resty.py b/examples/swagger2/restyresolver/resty.py new file mode 100755 index 0000000..42bfaa3 --- /dev/null +++ b/examples/swagger2/restyresolver/resty.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +import logging + +import firetail +from firetail.resolver import RestyResolver + +logging.basicConfig(level=logging.INFO) + +if __name__ == '__main__': + app = firetail.FlaskApp(__name__) + app.add_api('resty-api.yaml', + arguments={'title': 'RestyResolver Example'}, + resolver=RestyResolver('api')) + app.run(port=9090) diff --git a/examples/swagger2/sqlalchemy/README.rst b/examples/swagger2/sqlalchemy/README.rst new file mode 100644 index 0000000..7d19025 --- /dev/null +++ b/examples/swagger2/sqlalchemy/README.rst @@ -0,0 +1,15 @@ +================== +SQLAlchemy Example +================== + +A simple example of how one might use SQLAlchemy as a backing store for a +Firetail based application. + +Running: + +.. code-block:: bash + + $ sudo pip3 install -r requirements.txt + $ ./app.py + +Now open your browser and go to http://localhost:8080/ui/ to see the Swagger UI. diff --git a/examples/swagger2/sqlalchemy/app.py b/examples/swagger2/sqlalchemy/app.py new file mode 100755 index 0000000..6a5be4a --- /dev/null +++ b/examples/swagger2/sqlalchemy/app.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +import datetime +import logging + +import firetail +import orm +from firetail import NoContent + +db_session = None + + +def get_pets(limit, animal_type=None): + q = db_session.query(orm.Pet) + if animal_type: + q = q.filter(orm.Pet.animal_type == animal_type) + return [p.dump() for p in q][:limit] + + +def get_pet(pet_id): + pet = db_session.query(orm.Pet).filter(orm.Pet.id == pet_id).one_or_none() + return pet.dump() if pet is not None else ('Not found', 404) + + +def put_pet(pet_id, pet): + p = db_session.query(orm.Pet).filter(orm.Pet.id == pet_id).one_or_none() + pet['id'] = pet_id + if p is not None: + logging.info('Updating pet %s..', pet_id) + p.update(**pet) + else: + logging.info('Creating pet %s..', pet_id) + pet['created'] = datetime.datetime.utcnow() + db_session.add(orm.Pet(**pet)) + db_session.commit() + return NoContent, (200 if p is not None else 201) + + +def delete_pet(pet_id): + pet = db_session.query(orm.Pet).filter(orm.Pet.id == pet_id).one_or_none() + if pet is not None: + logging.info('Deleting pet %s..', pet_id) + db_session.query(orm.Pet).filter(orm.Pet.id == pet_id).delete() + db_session.commit() + return NoContent, 204 + else: + return NoContent, 404 + +logging.basicConfig(level=logging.INFO) +db_session = orm.init_db('sqlite:///:memory:') +app = firetail.FlaskApp(__name__) +app.add_api('swagger.yaml') + +application = app.app + + +@application.teardown_appcontext +def shutdown_session(exception=None): + db_session.remove() + + +if __name__ == '__main__': + app.run( + port=8080, + threaded=False # in-memory database isn't shared across threads + ) diff --git a/examples/swagger2/sqlalchemy/orm.py b/examples/swagger2/sqlalchemy/orm.py new file mode 100644 index 0000000..1514032 --- /dev/null +++ b/examples/swagger2/sqlalchemy/orm.py @@ -0,0 +1,32 @@ +from sqlalchemy import Column, DateTime, String, create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import scoped_session, sessionmaker + +Base = declarative_base() + + +class Pet(Base): + __tablename__ = 'pets' + id = Column(String(20), primary_key=True) + name = Column(String(100)) + animal_type = Column(String(20)) + created = Column(DateTime()) + + def update(self, id=None, name=None, animal_type=None, tags=None, created=None): + if name is not None: + self.name = name + if animal_type is not None: + self.animal_type = animal_type + if created is not None: + self.created = created + + def dump(self): + return {k: v for k, v in vars(self).items() if not k.startswith('_')} + + +def init_db(uri): + engine = create_engine(uri, convert_unicode=True) + db_session = scoped_session(sessionmaker(autocommit=False, autoflush=False, bind=engine)) + Base.query = db_session.query_property() + Base.metadata.create_all(bind=engine) + return db_session diff --git a/examples/swagger2/sqlalchemy/requirements.txt b/examples/swagger2/sqlalchemy/requirements.txt new file mode 100644 index 0000000..3fff5b2 --- /dev/null +++ b/examples/swagger2/sqlalchemy/requirements.txt @@ -0,0 +1,3 @@ +firetail>=1.0.97 +Flask>=0.10.1 +SQLAlchemy>=1.0.13 \ No newline at end of file diff --git a/examples/swagger2/sqlalchemy/swagger.yaml b/examples/swagger2/sqlalchemy/swagger.yaml new file mode 100644 index 0000000..65b40a2 --- /dev/null +++ b/examples/swagger2/sqlalchemy/swagger.yaml @@ -0,0 +1,111 @@ +swagger: '2.0' +info: + title: Pet Shop Example API + version: "0.1" +consumes: + - application/json +produces: + - application/json +paths: + /pets: + get: + tags: [Pets] + operationId: app.get_pets + summary: Get all pets + parameters: + - name: animal_type + in: query + type: string + pattern: "^[a-zA-Z0-9]*$" + - name: limit + in: query + type: integer + minimum: 0 + default: 100 + responses: + 200: + description: Return pets + schema: + type: array + items: + $ref: '#/definitions/Pet' + /pets/{pet_id}: + get: + tags: [Pets] + operationId: app.get_pet + summary: Get a single pet + parameters: + - $ref: '#/parameters/pet_id' + responses: + 200: + description: Return pet + schema: + $ref: '#/definitions/Pet' + 404: + description: Pet does not exist + put: + tags: [Pets] + operationId: app.put_pet + summary: Create or update a pet + parameters: + - $ref: '#/parameters/pet_id' + - name: pet + in: body + schema: + $ref: '#/definitions/Pet' + responses: + 200: + description: Pet updated + 201: + description: New pet created + delete: + tags: [Pets] + operationId: app.delete_pet + summary: Remove a pet + parameters: + - $ref: '#/parameters/pet_id' + responses: + 204: + description: Pet was deleted + 404: + description: Pet does not exist + + +parameters: + pet_id: + name: pet_id + description: Pet's Unique identifier + in: path + type: string + required: true + pattern: "^[a-zA-Z0-9-]+$" + +definitions: + Pet: + type: object + required: + - name + - animal_type + properties: + id: + type: string + description: Unique identifier + example: "123" + readOnly: true + name: + type: string + description: Pet's name + example: "Susie" + minLength: 1 + maxLength: 100 + animal_type: + type: string + description: Kind of animal + example: "cat" + minLength: 1 + created: + type: string + format: date-time + description: Creation time + example: "2015-07-07T15:49:51.230+02:00" + readOnly: true diff --git a/firetail/__init__.py b/firetail/__init__.py old mode 100644 new mode 100755 index 356e219..ea544df --- a/firetail/__init__.py +++ b/firetail/__init__.py @@ -1,42 +1,51 @@ -""" -Firetail is a framework that automagically handles HTTP requests based on OpenAPI Specification -(formerly known as Swagger Spec) of your API described in YAML format. Firetail allows you to -write an OpenAPI specification, then maps the endpoints to your Python functions; this makes it -unique, as many tools generate the specification based on your Python code. You can describe your -REST API in as much detail as you want; then Firetail guarantees that it will work as you -specified. -""" - -import sys - -import werkzeug.exceptions as exceptions # NOQA - -from .apis import AbstractAPI # NOQA -from .apps import AbstractApp # NOQA -from .decorators.produces import NoContent # NOQA -from .exceptions import ProblemException # NOQA -# add operation for backwards compatibility -from .operations import compat -from .problem import problem # NOQA -from .resolver import Resolution, Resolver, RestyResolver # NOQA -from .utils import not_installed_error # NOQA - -full_name = f'{__package__}.operation' -sys.modules[full_name] = sys.modules[compat.__name__] - - -try: - from flask import request # NOQA - - from .apis.flask_api import FlaskApi, context # NOQA - from .apps.flask_app import FlaskApp -except ImportError as e: # pragma: no cover - _flask_not_installed_error = not_installed_error(e) - FlaskApi = _flask_not_installed_error - FlaskApp = _flask_not_installed_error - -App = FlaskApp -Api = FlaskApi - -# This version is replaced during release process. -__version__ = '2022.4.dev1' +""" +Firetail is a framework that automagically handles HTTP requests based on OpenAPI Specification +(formerly known as Swagger Spec) of your API described in YAML format. Firetail allows you to +write an OpenAPI specification, then maps the endpoints to your Python functions; this makes it +unique, as many tools generate the specification based on your Python code. You can describe your +REST API in as much detail as you want; then Firetail guarantees that it will work as you +specified. +""" + +import sys + +import werkzeug.exceptions as exceptions # NOQA + +from .apis import AbstractAPI # NOQA +from .apps import AbstractApp # NOQA +from .decorators.produces import NoContent # NOQA +from .exceptions import ProblemException # NOQA + +# add operation for backwards compatibility +from .operations import compat +from .problem import problem # NOQA +from .resolver import Resolution, Resolver, RestyResolver # NOQA +from .utils import not_installed_error # NOQA + +full_name = f'{__package__}.operation' +sys.modules[full_name] = sys.modules[compat.__name__] + + +try: + from flask import request # NOQA + + from .apis.flask_api import FlaskApi, context # NOQA + from .apps.flask_app import FlaskApp +except ImportError as e: # pragma: no cover + _flask_not_installed_error = not_installed_error(e) + FlaskApi = _flask_not_installed_error + FlaskApp = _flask_not_installed_error + +App = FlaskApp +Api = FlaskApi + +try: + from .apis.aiohttp_api import AioHttpApi + from .apps.aiohttp_app import AioHttpApp +except ImportError as e: # pragma: no cover + _aiohttp_not_installed_error = not_installed_error(e) + AioHttpApi = _aiohttp_not_installed_error + AioHttpApp = _aiohttp_not_installed_error + +# This version is replaced during release process. +__version__ = '2022.11.dev19' diff --git a/firetail/__main__.py b/firetail/__main__.py index ac19b92..ee50787 100644 --- a/firetail/__main__.py +++ b/firetail/__main__.py @@ -1,7 +1,7 @@ -""" -This module provides an entrypoint for Firetail's CLI. -""" - -from firetail.cli import main # pragma: no cover - -main() # pragma: no cover +""" +This module provides an entrypoint for Firetail's CLI. +""" + +from firetail.cli import main # pragma: no cover + +main() # pragma: no cover diff --git a/firetail/apis/__init__.py b/firetail/apis/__init__.py index f162d7a..f550dfe 100644 --- a/firetail/apis/__init__.py +++ b/firetail/apis/__init__.py @@ -1,16 +1,16 @@ -""" -This module defines Firetail APIs. A firetail API takes in an OpenAPI specification and -translates the operations defined in it to a set of Firetail Operations. This set of operations -is implemented as a framework blueprint (A Flask blueprint or framework-specific equivalent), -which can be registered on the framework application. - -For each operation, the API resolves the user view function to link to the operation, wraps it -with a Firetail Operation which it configures based on the OpenAPI spec, and finally adds it as -a route on the framework blueprint. - -When the API is registered on the Firetail APP, the underlying framework blueprint is registered -on the framework app. -""" - - -from .abstract import AbstractAPI, AbstractSwaggerUIAPI # NOQA +""" +This module defines Firetail APIs. A firetail API takes in an OpenAPI specification and +translates the operations defined in it to a set of Firetail Operations. This set of operations +is implemented as a framework blueprint (A Flask blueprint or framework-specific equivalent), +which can be registered on the framework application. + +For each operation, the API resolves the user view function to link to the operation, wraps it +with a Firetail Operation which it configures based on the OpenAPI spec, and finally adds it as +a route on the framework blueprint. + +When the API is registered on the Firetail APP, the underlying framework blueprint is registered +on the framework app. +""" + + +from .abstract import AbstractAPI # NOQA diff --git a/firetail/apis/abstract.py b/firetail/apis/abstract.py index 53354a5..746843b 100644 --- a/firetail/apis/abstract.py +++ b/firetail/apis/abstract.py @@ -1,486 +1,464 @@ -""" -This module defines an AbstractAPI, which defines a standardized interface for a Firetail API. -""" - -import abc -import logging -import pathlib -import sys -import typing as t -from enum import Enum - -from ..decorators.produces import NoContent -from ..exceptions import ResolverError -from ..http_facts import METHODS -from ..jsonifier import Jsonifier -from ..lifecycle import FiretailResponse -from ..operations import make_operation -from ..options import FiretailOptions -from ..resolver import Resolver -from ..spec import Specification - -MODULE_PATH = pathlib.Path(__file__).absolute().parent.parent -SWAGGER_UI_URL = 'ui' - -logger = logging.getLogger('firetail.apis.abstract') - - -class AbstractAPIMeta(abc.ABCMeta): - - def __init__(cls, name, bases, attrs): - abc.ABCMeta.__init__(cls, name, bases, attrs) - cls._set_jsonifier() - - -class AbstractSpecAPI(metaclass=AbstractAPIMeta): - - def __init__( - self, - specification: t.Union[pathlib.Path, str, dict], - base_path: t.Optional[str] = None, - arguments: t.Optional[dict] = None, - options: t.Optional[dict] = None, - *args, - **kwargs - ): - """Base API class with only minimal behavior related to the specification.""" - logger.debug('Loading specification: %s', specification, - extra={'swagger_yaml': specification, - 'base_path': base_path, - 'arguments': arguments}) - - # Avoid validator having ability to modify specification - self.specification = Specification.load( - specification, arguments=arguments) - - logger.debug('Read specification', extra={'spec': self.specification}) - - self.options = FiretailOptions( - options, oas_version=self.specification.version) - - logger.debug('Options Loaded', - extra={'swagger_ui': self.options.openapi_console_ui_available, - 'swagger_path': self.options.openapi_console_ui_from_dir, - 'swagger_url': self.options.openapi_console_ui_path}) - - self._set_base_path(base_path) - - def _set_base_path(self, base_path: t.Optional[str] = None) -> None: - if base_path is not None: - # update spec to include user-provided base_path - self.specification.base_path = base_path - self.base_path = base_path - else: - self.base_path = self.specification.base_path - - @classmethod - def _set_jsonifier(cls): - cls.jsonifier = Jsonifier() - - -class AbstractSwaggerUIAPI(AbstractSpecAPI): - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - if self.options.openapi_spec_available: - self.add_openapi_json() - self.add_openapi_yaml() - - if self.options.openapi_console_ui_available: - self.add_swagger_ui() - - @abc.abstractmethod - def add_openapi_json(self): - """ - Adds openapi spec to {base_path}/openapi.json - (or {base_path}/swagger.json for swagger2) - """ - - @abc.abstractmethod - def add_openapi_yaml(self): - """ - Adds openapi spec to {base_path}/openapi.yaml - (or {base_path}/swagger.yaml for swagger2) - """ - - @abc.abstractmethod - def add_swagger_ui(self): - """ - Adds swagger ui to {base_path}/ui/ - """ - - -class AbstractAPI(AbstractSpecAPI): - """ - Defines an abstract interface for a Swagger API - """ - - def __init__(self, specification, base_path=None, arguments=None, - validate_responses=False, strict_validation=False, resolver=None, - auth_all_paths=False, debug=False, resolver_error_handler=None, - validator_map=None, pythonic_params=False, pass_context_arg_name=None, options=None, - ): - """ - :type specification: pathlib.Path | dict - :type base_path: str | None - :type arguments: dict | None - :type validate_responses: bool - :type strict_validation: bool - :type auth_all_paths: bool - :type debug: bool - :param validator_map: Custom validators for the types "parameter", "body" and "response". - :type validator_map: dict - :param resolver: Callable that maps operationID to a function - :param resolver_error_handler: If given, a callable that generates an - Operation used for handling ResolveErrors - :type resolver_error_handler: callable | None - :param pythonic_params: When True CamelCase parameters are converted to snake_case and an underscore is appended - to any shadowed built-ins - :type pythonic_params: bool - :param options: New style options dictionary. - :type options: dict | None - :param pass_context_arg_name: If not None URL request handling functions with an argument matching this name - will be passed the framework's request context. - :type pass_context_arg_name: str | None - """ - self.debug = debug - self.validator_map = validator_map - self.resolver_error_handler = resolver_error_handler - - logger.debug('Loading specification: %s', specification, - extra={'swagger_yaml': specification, - 'base_path': base_path, - 'arguments': arguments, - 'auth_all_paths': auth_all_paths}) - - # Avoid validator having ability to modify specification - self.specification = Specification.load( - specification, arguments=arguments) - - logger.debug('Read specification', extra={'spec': self.specification}) - - self.options = FiretailOptions( - options, oas_version=self.specification.version) - - logger.debug('Options Loaded', - extra={'swagger_ui': self.options.openapi_console_ui_available, - 'swagger_path': self.options.openapi_console_ui_from_dir, - 'swagger_url': self.options.openapi_console_ui_path}) - - self._set_base_path(base_path) - - logger.debug('Security Definitions: %s', - self.specification.security_definitions) - - self.resolver = resolver or Resolver() - - logger.debug('Validate Responses: %s', str(validate_responses)) - self.validate_responses = validate_responses - - logger.debug('Strict Request Validation: %s', str(strict_validation)) - self.strict_validation = strict_validation - - logger.debug('Pythonic params: %s', str(pythonic_params)) - self.pythonic_params = pythonic_params - - logger.debug('pass_context_arg_name: %s', pass_context_arg_name) - self.pass_context_arg_name = pass_context_arg_name - - self.security_handler_factory = self.make_security_handler_factory( - pass_context_arg_name) - - super().__init__(specification, base_path=base_path, - arguments=arguments, options=options) - - self.add_paths() - - if auth_all_paths: - self.add_auth_on_not_found( - self.specification.security, - self.specification.security_definitions - ) - - @abc.abstractmethod - def add_auth_on_not_found(self, security, security_definitions): - """ - Adds a 404 error handler to authenticate and only expose the 404 status if the security validation pass. - """ - - @staticmethod - @abc.abstractmethod - def make_security_handler_factory(pass_context_arg_name): - """ Create SecurityHandlerFactory to create all security check handlers """ - - def add_operation(self, path, method): - """ - Adds one operation to the api. - - This method uses the OperationID identify the module and function that will handle the operation - - From Swagger Specification: - - **OperationID** - - A friendly name for the operation. The id MUST be unique among all operations described in the API. - Tools and libraries MAY use the operation id to uniquely identify an operation. - - :type method: str - :type path: str - """ - operation = make_operation( - self.specification, - self, - path, - method, - self.resolver, - validate_responses=self.validate_responses, - validator_map=self.validator_map, - strict_validation=self.strict_validation, - pythonic_params=self.pythonic_params, - uri_parser_class=self.options.uri_parser_class, - pass_context_arg_name=self.pass_context_arg_name - ) - self._add_operation_internal(method, path, operation) - - @abc.abstractmethod - def _add_operation_internal(self, method, path, operation): - """ - Adds the operation according to the user framework in use. - It will be used to register the operation on the user framework router. - """ - - def _add_resolver_error_handler(self, method, path, err): - """ - Adds a handler for ResolverError for the given method and path. - """ - operation = self.resolver_error_handler( - err, - security=self.specification.security, - security_definitions=self.specification.security_definitions - ) - self._add_operation_internal(method, path, operation) - - def add_paths(self, paths=None): - """ - Adds the paths defined in the specification as endpoints - - :type paths: list - """ - paths = paths or self.specification.get('paths', dict()) - for path, methods in paths.items(): - logger.debug('Adding %s%s...', self.base_path, path) - - for method in methods: - if method not in METHODS: - continue - try: - self.add_operation(path, method) - except ResolverError as err: - # If we have an error handler for resolver errors, add it as an operation. - # Otherwise treat it as any other error. - if self.resolver_error_handler is not None: - self._add_resolver_error_handler(method, path, err) - else: - self._handle_add_operation_error( - path, method, err.exc_info) - except Exception: - # All other relevant exceptions should be handled as well. - self._handle_add_operation_error( - path, method, sys.exc_info()) - - def _handle_add_operation_error(self, path, method, exc_info): - url = f'{self.base_path}{path}' - error_msg = 'Failed to add operation for {method} {url}'.format( - method=method.upper(), - url=url) - if self.debug: - logger.exception(error_msg) - else: - logger.error(error_msg) - _type, value, traceback = exc_info - raise value.with_traceback(traceback) - - @classmethod - @abc.abstractmethod - def get_request(self, *args, **kwargs): - """ - This method converts the user framework request to a FiretailRequest. - """ - - @classmethod - @abc.abstractmethod - def get_response(self, response, mimetype=None, request=None): - """ - This method converts a handler response to a framework response. - This method should just retrieve response from handler then call `cls._get_response`. - :param response: A response to cast (tuple, framework response, etc). - :param mimetype: The response mimetype. - :type mimetype: Union[None, str] - :param request: The request associated with this response (the user framework request). - """ - - @classmethod - def _get_response(cls, response, mimetype=None, extra_context=None): - """ - This method converts a handler response to a framework response. - The response can be a FiretailResponse, an operation handler, a framework response or a tuple. - Other type than FiretailResponse are handled by `cls._response_from_handler` - :param response: A response to cast (tuple, framework response, etc). - :param mimetype: The response mimetype. - :type mimetype: Union[None, str] - :param extra_context: dict of extra details, like url, to include in logs - :type extra_context: Union[None, dict] - """ - if extra_context is None: - extra_context = {} - logger.debug('Getting data and status code', - extra={ - 'data': response, - 'data_type': type(response), - **extra_context - }) - - if isinstance(response, FiretailResponse): - framework_response = cls._firetail_to_framework_response( - response, mimetype, extra_context) - else: - framework_response = cls._response_from_handler( - response, mimetype, extra_context) - - logger.debug('Got framework response', - extra={ - 'response': framework_response, - 'response_type': type(framework_response), - **extra_context - }) - return framework_response - - @classmethod - def _response_from_handler( - cls, - response: t.Union[t.Any, str, t.Tuple[str], t.Tuple[str, int], t.Tuple[str, int, dict]], - mimetype: str, - extra_context: t.Optional[dict] = None - ) -> t.Any: - """ - Create a framework response from the operation handler data. - An operation handler can return: - - a framework response - - a body (str / binary / dict / list), a response will be created - with a status code 200 by default and empty headers. - - a tuple of (body: str, status_code: int) - - a tuple of (body: str, status_code: int, headers: dict) - - :param response: A response from an operation handler. - :param mimetype: The response mimetype. - :param extra_context: dict of extra details, like url, to include in logs - """ - if cls._is_framework_response(response): - return response - - if isinstance(response, tuple): - len_response = len(response) - if len_response == 1: - data, = response - return cls._build_response(mimetype=mimetype, data=data, extra_context=extra_context) - if len_response == 2: - if isinstance(response[1], (int, Enum)): - data, status_code = response - return cls._build_response(mimetype=mimetype, data=data, status_code=status_code, extra_context=extra_context) - else: - data, headers = response - return cls._build_response(mimetype=mimetype, data=data, headers=headers, extra_context=extra_context) - elif len_response == 3: - data, status_code, headers = response - return cls._build_response(mimetype=mimetype, data=data, status_code=status_code, headers=headers, extra_context=extra_context) - else: - raise TypeError( - 'The view function did not return a valid response tuple.' - ' The tuple must have the form (body), (body, status, headers),' - ' (body, status), or (body, headers).' - ) - else: - return cls._build_response(mimetype=mimetype, data=response, extra_context=extra_context) - - @classmethod - def get_firetail_response(cls, response, mimetype=None): - """ Cast framework dependent response to FiretailResponse used for schema validation """ - if isinstance(response, FiretailResponse): - return response - - if not cls._is_framework_response(response): - response = cls._response_from_handler(response, mimetype) - return cls._framework_to_firetail_response(response=response, mimetype=mimetype) - - @classmethod - @abc.abstractmethod - def _is_framework_response(cls, response): - """ Return True if `response` is a framework response class """ - - @classmethod - @abc.abstractmethod - def _framework_to_firetail_response(cls, response, mimetype): - """ Cast framework response class to FiretailResponse used for schema validation """ - - @classmethod - @abc.abstractmethod - def _firetail_to_framework_response(cls, response, mimetype, extra_context=None): - """ Cast FiretailResponse to framework response class """ - - @classmethod - @abc.abstractmethod - def _build_response(cls, data, mimetype, content_type=None, status_code=None, headers=None, extra_context=None): - """ - Create a framework response from the provided arguments. - :param data: Body data. - :param content_type: The response mimetype. - :type content_type: str - :param content_type: The response status code. - :type status_code: int - :param headers: The response status code. - :type headers: Union[Iterable[Tuple[str, str]], Dict[str, str]] - :param extra_context: dict of extra details, like url, to include in logs - :type extra_context: Union[None, dict] - :return A framework response. - :rtype Response - """ - - @classmethod - def _prepare_body_and_status_code(cls, data, mimetype, status_code=None, extra_context=None): - if data is NoContent: - data = None - - if status_code is None: - if data is None: - status_code = 204 - mimetype = None - else: - status_code = 200 - elif hasattr(status_code, "value"): - # If we got an enum instead of an int, extract the value. - status_code = status_code.value - - if data is not None: - body, mimetype = cls._serialize_data(data, mimetype) - else: - body = data - - if extra_context is None: - extra_context = {} - logger.debug('Prepared body and status code (%d)', - status_code, - extra={ - 'body': body, - **extra_context - }) - - return body, status_code, mimetype - - @classmethod - @abc.abstractmethod - def _serialize_data(cls, data, mimetype): - pass - - def json_loads(self, data): - return self.jsonifier.loads(data) +""" +This module defines an AbstractAPI, which defines a standardized interface for a Firetail API. +""" + +import abc +import logging +import pathlib +import sys +import typing as t +import warnings +from enum import Enum + +from ..decorators.produces import NoContent +from ..exceptions import ResolverError +from ..http_facts import METHODS +from ..jsonifier import Jsonifier +from ..lifecycle import FiretailResponse +from ..operations import make_operation +from ..options import FiretailOptions +from ..resolver import Resolver +from ..spec import Specification +from ..utils import is_json_mimetype + +MODULE_PATH = pathlib.Path(__file__).absolute().parent.parent +SWAGGER_UI_URL = 'ui' + +logger = logging.getLogger('firetail.apis.abstract') + + +class AbstractAPIMeta(abc.ABCMeta): + + def __init__(cls, name, bases, attrs): + abc.ABCMeta.__init__(cls, name, bases, attrs) + cls._set_jsonifier() + + +class AbstractAPI(metaclass=AbstractAPIMeta): + """ + Defines an abstract interface for a Swagger API + """ + + def __init__(self, specification, base_path=None, arguments=None, + validate_responses=False, strict_validation=False, resolver=None, + auth_all_paths=False, debug=False, resolver_error_handler=None, + validator_map=None, pythonic_params=False, pass_context_arg_name=None, options=None, + ): + """ + :type specification: pathlib.Path | dict + :type base_path: str | None + :type arguments: dict | None + :type validate_responses: bool + :type strict_validation: bool + :type auth_all_paths: bool + :type debug: bool + :param validator_map: Custom validators for the types "parameter", "body" and "response". + :type validator_map: dict + :param resolver: Callable that maps operationID to a function + :param resolver_error_handler: If given, a callable that generates an + Operation used for handling ResolveErrors + :type resolver_error_handler: callable | None + :param pythonic_params: When True CamelCase parameters are converted to snake_case and an underscore is appended + to any shadowed built-ins + :type pythonic_params: bool + :param options: New style options dictionary. + :type options: dict | None + :param pass_context_arg_name: If not None URL request handling functions with an argument matching this name + will be passed the framework's request context. + :type pass_context_arg_name: str | None + """ + self.debug = debug + self.validator_map = validator_map + self.resolver_error_handler = resolver_error_handler + + logger.debug('Loading specification: %s', specification, + extra={'swagger_yaml': specification, + 'base_path': base_path, + 'arguments': arguments, + 'auth_all_paths': auth_all_paths}) + + # Avoid validator having ability to modify specification + self.specification = Specification.load(specification, arguments=arguments) + + logger.debug('Read specification', extra={'spec': self.specification}) + + self.options = FiretailOptions(options, oas_version=self.specification.version) + + logger.debug('Options Loaded', + extra={'swagger_ui': self.options.openapi_console_ui_available, + 'swagger_path': self.options.openapi_console_ui_from_dir, + 'swagger_url': self.options.openapi_console_ui_path}) + + self._set_base_path(base_path) + + logger.debug('Security Definitions: %s', self.specification.security_definitions) + + self.resolver = resolver or Resolver() + + logger.debug('Validate Responses: %s', str(validate_responses)) + self.validate_responses = validate_responses + + logger.debug('Strict Request Validation: %s', str(strict_validation)) + self.strict_validation = strict_validation + + logger.debug('Pythonic params: %s', str(pythonic_params)) + self.pythonic_params = pythonic_params + + logger.debug('pass_context_arg_name: %s', pass_context_arg_name) + self.pass_context_arg_name = pass_context_arg_name + + self.security_handler_factory = self.make_security_handler_factory(pass_context_arg_name) + + if self.options.openapi_spec_available: + self.add_openapi_json() + self.add_openapi_yaml() + + if self.options.openapi_console_ui_available: + self.add_swagger_ui() + + self.add_paths() + + if auth_all_paths: + self.add_auth_on_not_found( + self.specification.security, + self.specification.security_definitions + ) + + def _set_base_path(self, base_path=None): + if base_path is not None: + # update spec to include user-provided base_path + self.specification.base_path = base_path + self.base_path = base_path + else: + self.base_path = self.specification.base_path + + @abc.abstractmethod + def add_openapi_json(self): + """ + Adds openapi spec to {base_path}/openapi.json + (or {base_path}/swagger.json for swagger2) + """ + + @abc.abstractmethod + def add_swagger_ui(self): + """ + Adds swagger ui to {base_path}/ui/ + """ + + @abc.abstractmethod + def add_auth_on_not_found(self, security, security_definitions): + """ + Adds a 404 error handler to authenticate and only expose the 404 status if the security validation pass. + """ + + @staticmethod + @abc.abstractmethod + def make_security_handler_factory(pass_context_arg_name): + """ Create SecurityHandlerFactory to create all security check handlers """ + + def add_operation(self, path, method): + """ + Adds one operation to the api. + + This method uses the OperationID identify the module and function that will handle the operation + + From Swagger Specification: + + **OperationID** + + A friendly name for the operation. The id MUST be unique among all operations described in the API. + Tools and libraries MAY use the operation id to uniquely identify an operation. + + :type method: str + :type path: str + """ + operation = make_operation( + self.specification, + self, + path, + method, + self.resolver, + validate_responses=self.validate_responses, + validator_map=self.validator_map, + strict_validation=self.strict_validation, + pythonic_params=self.pythonic_params, + uri_parser_class=self.options.uri_parser_class, + pass_context_arg_name=self.pass_context_arg_name + ) + self._add_operation_internal(method, path, operation) + + @abc.abstractmethod + def _add_operation_internal(self, method, path, operation): + """ + Adds the operation according to the user framework in use. + It will be used to register the operation on the user framework router. + """ + + def _add_resolver_error_handler(self, method, path, err): + """ + Adds a handler for ResolverError for the given method and path. + """ + operation = self.resolver_error_handler( + err, + security=self.specification.security, + security_definitions=self.specification.security_definitions + ) + self._add_operation_internal(method, path, operation) + + def add_paths(self, paths=None): + """ + Adds the paths defined in the specification as endpoints + + :type paths: list + """ + paths = paths or self.specification.get('paths', dict()) + for path, methods in paths.items(): + logger.debug('Adding %s%s...', self.base_path, path) + + for method in methods: + if method not in METHODS: + continue + try: + self.add_operation(path, method) + except ResolverError as err: + # If we have an error handler for resolver errors, add it as an operation. + # Otherwise treat it as any other error. + if self.resolver_error_handler is not None: + self._add_resolver_error_handler(method, path, err) + else: + self._handle_add_operation_error(path, method, err.exc_info) + except Exception: + # All other relevant exceptions should be handled as well. + self._handle_add_operation_error(path, method, sys.exc_info()) + + def _handle_add_operation_error(self, path, method, exc_info): + url = f'{self.base_path}{path}' + error_msg = 'Failed to add operation for {method} {url}'.format( + method=method.upper(), + url=url) + if self.debug: + logger.exception(error_msg) + else: + logger.error(error_msg) + _type, value, traceback = exc_info + raise value.with_traceback(traceback) + + @classmethod + @abc.abstractmethod + def get_request(self, *args, **kwargs): + """ + This method converts the user framework request to a FiretailRequest. + """ + + @classmethod + @abc.abstractmethod + def get_response(self, response, mimetype=None, request=None): + """ + This method converts a handler response to a framework response. + This method should just retrieve response from handler then call `cls._get_response`. + It is mainly here to handle AioHttp async handler. + :param response: A response to cast (tuple, framework response, etc). + :param mimetype: The response mimetype. + :type mimetype: Union[None, str] + :param request: The request associated with this response (the user framework request). + """ + + @classmethod + def _get_response(cls, response, mimetype=None, extra_context=None): + """ + This method converts a handler response to a framework response. + The response can be a FiretailResponse, an operation handler, a framework response or a tuple. + Other type than FiretailResponse are handled by `cls._response_from_handler` + :param response: A response to cast (tuple, framework response, etc). + :param mimetype: The response mimetype. + :type mimetype: Union[None, str] + :param extra_context: dict of extra details, like url, to include in logs + :type extra_context: Union[None, dict] + """ + if extra_context is None: + extra_context = {} + logger.debug('Getting data and status code', + extra={ + 'data': response, + 'data_type': type(response), + **extra_context + }) + + if isinstance(response, FiretailResponse): + framework_response = cls._firetail_to_framework_response(response, mimetype, extra_context) + else: + framework_response = cls._response_from_handler(response, mimetype, extra_context) + + logger.debug('Got framework response', + extra={ + 'response': framework_response, + 'response_type': type(framework_response), + **extra_context + }) + return framework_response + + @classmethod + def _response_from_handler( + cls, + response: t.Union[t.Any, str, t.Tuple[str], t.Tuple[str, int], t.Tuple[str, int, dict]], + mimetype: str, + extra_context: t.Optional[dict] = None + ) -> t.Any: + """ + Create a framework response from the operation handler data. + An operation handler can return: + - a framework response + - a body (str / binary / dict / list), a response will be created + with a status code 200 by default and empty headers. + - a tuple of (body: str, status_code: int) + - a tuple of (body: str, status_code: int, headers: dict) + + :param response: A response from an operation handler. + :param mimetype: The response mimetype. + :param extra_context: dict of extra details, like url, to include in logs + """ + if cls._is_framework_response(response): + return response + + if isinstance(response, tuple): + len_response = len(response) + if len_response == 1: + data, = response + return cls._build_response(mimetype=mimetype, data=data, extra_context=extra_context) + if len_response == 2: + if isinstance(response[1], (int, Enum)): + data, status_code = response + return cls._build_response(mimetype=mimetype, data=data, status_code=status_code, extra_context=extra_context) + else: + data, headers = response + return cls._build_response(mimetype=mimetype, data=data, headers=headers, extra_context=extra_context) + elif len_response == 3: + data, status_code, headers = response + return cls._build_response(mimetype=mimetype, + data=data, + status_code=status_code, + headers=headers, + extra_context=extra_context) + else: + raise TypeError( + 'The view function did not return a valid response tuple.' + ' The tuple must have the form (body), (body, status, headers),' + ' (body, status), or (body, headers).' + ) + else: + return cls._build_response(mimetype=mimetype, data=response, extra_context=extra_context) + + @classmethod + def get_firetail_response(cls, response, mimetype=None): + """ Cast framework dependent response to FiretailResponse used for schema validation """ + if isinstance(response, FiretailResponse): + # If body in FiretailResponse is not byte, it may not pass schema validation. + # In this case, rebuild response with aiohttp to have consistency + if response.body is None or isinstance(response.body, bytes): + return response + else: + response = cls._build_response( + data=response.body, + mimetype=mimetype, + content_type=response.content_type, + headers=response.headers, + status_code=response.status_code + ) + + if not cls._is_framework_response(response): + response = cls._response_from_handler(response, mimetype) + return cls._framework_to_firetail_response(response=response, mimetype=mimetype) + + @classmethod + @abc.abstractmethod + def _is_framework_response(cls, response): + """ Return True if `response` is a framework response class """ + + @classmethod + @abc.abstractmethod + def _framework_to_firetail_response(cls, response, mimetype): + """ Cast framework response class to FiretailResponse used for schema validation """ + + @classmethod + @abc.abstractmethod + def _firetail_to_framework_response(cls, response, mimetype, extra_context=None): + """ Cast FiretailResponse to framework response class """ + + @classmethod + @abc.abstractmethod + def _build_response(cls, data, mimetype, content_type=None, status_code=None, headers=None, extra_context=None): + """ + Create a framework response from the provided arguments. + :param data: Body data. + :param content_type: The response mimetype. + :type content_type: str + :param content_type: The response status code. + :type status_code: int + :param headers: The response status code. + :type headers: Union[Iterable[Tuple[str, str]], Dict[str, str]] + :param extra_context: dict of extra details, like url, to include in logs + :type extra_context: Union[None, dict] + :return A framework response. + :rtype Response + """ + + @classmethod + def _prepare_body_and_status_code(cls, data, mimetype, status_code=None, extra_context=None): + if data is NoContent: + data = None + + if status_code is None: + if data is None: + status_code = 204 + mimetype = None + else: + status_code = 200 + elif hasattr(status_code, "value"): + # If we got an enum instead of an int, extract the value. + status_code = status_code.value + + if data is not None: + body, mimetype = cls._serialize_data(data, mimetype) + else: + body = data + + if extra_context is None: + extra_context = {} + logger.debug('Prepared body and status code (%d)', + status_code, + extra={ + 'body': body, + **extra_context + }) + + return body, status_code, mimetype + + @classmethod + def _serialize_data(cls, data, mimetype): + # TODO: Harmonize with flask_api. Currently this is the backwards compatible with aiohttp_api._cast_body. + if not isinstance(data, bytes): + if isinstance(mimetype, str) and is_json_mimetype(mimetype): + body = cls.jsonifier.dumps(data) + elif isinstance(data, str): + body = data + else: + warnings.warn( + "Implicit (aiohttp) serialization with str() will change in the next major version. " + "This is triggered because a non-JSON response body is being stringified. " + "This will be replaced by something that is mimetype-specific and may " + "serialize some things as JSON or throw an error instead of silently " + "stringifying unknown response bodies. " + "Please make sure to specify media/mime types in your specs.", + FutureWarning # a Deprecation targeted at application users. + ) + body = str(data) + else: + body = data + return body, mimetype + + def json_loads(self, data): + return self.jsonifier.loads(data) + + @classmethod + def _set_jsonifier(cls): + cls.jsonifier = Jsonifier() diff --git a/firetail/apis/aiohttp_api.py b/firetail/apis/aiohttp_api.py new file mode 100644 index 0000000..55be3e9 --- /dev/null +++ b/firetail/apis/aiohttp_api.py @@ -0,0 +1,450 @@ +""" +This module defines an AioHttp Firetail API which implements translations between AioHttp and +Firetail requests / responses. +""" + +import asyncio +import logging +import re +import traceback +from contextlib import suppress +from http import HTTPStatus +from urllib.parse import parse_qs + +import aiohttp_jinja2 +import jinja2 +from aiohttp import web +from aiohttp.web_exceptions import HTTPNotFound, HTTPPermanentRedirect +from aiohttp.web_middlewares import normalize_path_middleware +from werkzeug.exceptions import HTTPException as werkzeug_HTTPException + +from firetail.apis.abstract import AbstractAPI +from firetail.exceptions import ProblemException +from firetail.handlers import AuthErrorHandler +from firetail.jsonifier import JSONEncoder, Jsonifier +from firetail.lifecycle import FiretailRequest, FiretailResponse +from firetail.problem import problem +from firetail.security import AioHttpSecurityHandlerFactory +from firetail.utils import yamldumper + +logger = logging.getLogger('firetail.apis.aiohttp_api') + + +def _generic_problem(http_status: HTTPStatus, exc: Exception = None): + extra = None + if exc is not None: + loop = asyncio.get_event_loop() + if loop.get_debug(): + tb = None + with suppress(Exception): + tb = traceback.format_exc() + if tb: + extra = {"traceback": tb} + + return problem( + status=http_status.value, + title=http_status.phrase, + detail=http_status.description, + ext=extra, + ) + + +@web.middleware +async def problems_middleware(request, handler): + try: + response = await handler(request) + except ProblemException as exc: + response = problem(status=exc.status, detail=exc.detail, title=exc.title, + type=exc.type, instance=exc.instance, headers=exc.headers, ext=exc.ext) + except (werkzeug_HTTPException, _HttpNotFoundError) as exc: + response = problem(status=exc.code, title=exc.name, detail=exc.description) + except web.HTTPError as exc: + if exc.text == f"{exc.status}: {exc.reason}": + detail = HTTPStatus(exc.status).description + else: + detail = exc.text + response = problem(status=exc.status, title=exc.reason, detail=detail) + except ( + web.HTTPException, # eg raised HTTPRedirection or HTTPSuccessful + asyncio.CancelledError, # skipped in default web_protocol + ): + # leave this to default handling in aiohttp.web_protocol.RequestHandler.start() + raise + except asyncio.TimeoutError as exc: + # overrides 504 from aiohttp.web_protocol.RequestHandler.start() + logger.debug('Request handler timed out.', exc_info=exc) + response = _generic_problem(HTTPStatus.GATEWAY_TIMEOUT, exc) + except Exception as exc: + # overrides 500 from aiohttp.web_protocol.RequestHandler.start() + logger.exception('Error handling request', exc_info=exc) + response = _generic_problem(HTTPStatus.INTERNAL_SERVER_ERROR, exc) + + if isinstance(response, FiretailResponse): + response = await AioHttpApi.get_response(response) + return response + + +class AioHttpApi(AbstractAPI): + def __init__(self, *args, **kwargs): + # NOTE we use HTTPPermanentRedirect (308) because + # clients sometimes turn POST requests into GET requests + # on 301, 302, or 303 + # see https://tools.ietf.org/html/rfc7538 + trailing_slash_redirect = normalize_path_middleware( + append_slash=True, + redirect_class=HTTPPermanentRedirect + ) + self.subapp = web.Application( + middlewares=[ + problems_middleware, + trailing_slash_redirect + ] + ) + AbstractAPI.__init__(self, *args, **kwargs) + + aiohttp_jinja2.setup( + self.subapp, + loader=jinja2.FileSystemLoader( + str(self.options.openapi_console_ui_from_dir) + ) + ) + middlewares = self.options.as_dict().get('middlewares', []) + self.subapp.middlewares.extend(middlewares) + + @staticmethod + def make_security_handler_factory(pass_context_arg_name): + """ Create default SecurityHandlerFactory to create all security check handlers """ + return AioHttpSecurityHandlerFactory(pass_context_arg_name) + + def _set_base_path(self, base_path): + AbstractAPI._set_base_path(self, base_path) + self._api_name = AioHttpApi.normalize_string(self.base_path) + + @staticmethod + def normalize_string(string): + return re.sub(r'[^a-zA-Z0-9]', '_', string.strip('/')) + + def _base_path_for_prefix(self, request): + """ + returns a modified basePath which includes the incoming request's + path prefix. + """ + base_path = self.base_path + if not request.path.startswith(self.base_path): + prefix = request.path.split(self.base_path)[0] + base_path = prefix + base_path + return base_path + + def _spec_for_prefix(self, request): + """ + returns a spec with a modified basePath / servers block + which corresponds to the incoming request path. + This is needed when behind a path-altering reverse proxy. + """ + base_path = self._base_path_for_prefix(request) + return self.specification.with_base_path(base_path).raw + + def add_openapi_json(self): + """ + Adds openapi json to {base_path}/openapi.json + (or {base_path}/swagger.json for swagger2) + """ + logger.debug('Adding spec json: %s/%s', self.base_path, + self.options.openapi_spec_path) + self.subapp.router.add_route( + 'GET', + self.options.openapi_spec_path, + self._get_openapi_json + ) + + def add_openapi_yaml(self): + """ + Adds openapi json to {base_path}/openapi.json + (or {base_path}/swagger.json for swagger2) + """ + if not self.options.openapi_spec_path.endswith("json"): + return + + openapi_spec_path_yaml = \ + self.options.openapi_spec_path[:-len("json")] + "yaml" + logger.debug('Adding spec yaml: %s/%s', self.base_path, + openapi_spec_path_yaml) + self.subapp.router.add_route( + 'GET', + openapi_spec_path_yaml, + self._get_openapi_yaml + ) + + async def _get_openapi_json(self, request): + return web.Response( + status=200, + content_type='application/json', + body=self.jsonifier.dumps(self._spec_for_prefix(request)) + ) + + async def _get_openapi_yaml(self, request): + return web.Response( + status=200, + content_type='text/yaml', + body=yamldumper(self._spec_for_prefix(request)) + ) + + def add_swagger_ui(self): + """ + Adds swagger ui to {base_path}/ui/ + """ + console_ui_path = self.options.openapi_console_ui_path.strip().rstrip('/') + logger.debug('Adding swagger-ui: %s%s/', + self.base_path, + console_ui_path) + + for path in ( + console_ui_path + '/', + console_ui_path + '/index.html', + ): + self.subapp.router.add_route( + 'GET', + path, + self._get_swagger_ui_home + ) + + if self.options.openapi_console_ui_config is not None: + self.subapp.router.add_route( + 'GET', + console_ui_path + '/swagger-ui-config.json', + self._get_swagger_ui_config + ) + + # we have to add an explicit redirect instead of relying on the + # normalize_path_middleware because we also serve static files + # from this dir (below) + + async def redirect(request): + raise web.HTTPMovedPermanently( + location=self.base_path + console_ui_path + '/' + ) + + self.subapp.router.add_route( + 'GET', + console_ui_path, + redirect + ) + + # this route will match and get a permission error when trying to + # serve index.html, so we add the redirect above. + self.subapp.router.add_static( + console_ui_path, + path=str(self.options.openapi_console_ui_from_dir), + name='swagger_ui_static' + ) + + @aiohttp_jinja2.template('index.j2') + async def _get_swagger_ui_home(self, req): + base_path = self._base_path_for_prefix(req) + template_variables = { + 'openapi_spec_url': (base_path + self.options.openapi_spec_path), + **self.options.openapi_console_ui_index_template_variables, + } + if self.options.openapi_console_ui_config is not None: + template_variables['configUrl'] = 'swagger-ui-config.json' + return template_variables + + async def _get_swagger_ui_config(self, req): + return web.Response( + status=200, + content_type='text/json', + body=self.jsonifier.dumps(self.options.openapi_console_ui_config) + ) + + def add_auth_on_not_found(self, security, security_definitions): + """ + Adds a 404 error handler to authenticate and only expose the 404 status if the security validation pass. + """ + logger.debug('Adding path not found authentication') + not_found_error = AuthErrorHandler( + self, _HttpNotFoundError(), + security=security, + security_definitions=security_definitions + ) + endpoint_name = f"{self._api_name}_not_found" + self.subapp.router.add_route( + '*', + '/{not_found_path}', + not_found_error.function, + name=endpoint_name + ) + + def _add_operation_internal(self, method, path, operation): + method = method.upper() + operation_id = operation.operation_id or path + + logger.debug('... Adding %s -> %s', method, operation_id, + extra=vars(operation)) + + handler = operation.function + endpoint_name = '{}_{}_{}'.format( + self._api_name, + AioHttpApi.normalize_string(path), + method.lower() + ) + self.subapp.router.add_route( + method, path, handler, name=endpoint_name + ) + + if not path.endswith('/'): + self.subapp.router.add_route( + method, path + '/', handler, name=endpoint_name + '_' + ) + + @classmethod + async def get_request(cls, req): + """Convert aiohttp request to firetail + + :param req: instance of aiohttp.web.Request + :return: firetail request instance + :rtype: FiretailRequest + """ + url = str(req.url) + + logger.debug( + 'Getting data and status code', + extra={ + # has_body | can_read_body report if + # body has been read or not + # body_exists refers to underlying stream of data + 'body_exists': req.body_exists, + 'can_read_body': req.can_read_body, + 'content_type': req.content_type, + 'url': url, + }, + ) + + query = parse_qs(req.rel_url.query_string) + headers = req.headers + body = None + + # Note: if request is not 'application/x-www-form-urlencoded' nor 'multipart/form-data', + # then `post_data` will be left an empty dict and the stream will not be consumed. + post_data = await req.post() + + files = {} + form = {} + + if post_data: + logger.debug('Reading multipart data from request') + for k, v in post_data.items(): + if isinstance(v, web.FileField): + if k in files: + # if multiple files arrive under the same name in the + # request, downstream requires that we put them all into + # a list under the same key in the files dict. + if isinstance(files[k], list): + files[k].append(v) + else: + files[k] = [files[k], v] + else: + files[k] = v + else: + # put normal fields as an array, that's how werkzeug does that for Flask + # and that's what Firetail expects in its processing functions + form[k] = [v] + body = b'' + else: + logger.debug('Reading data from request') + body = await req.read() + + return FiretailRequest(url=url, + method=req.method.lower(), + path_params=dict(req.match_info), + query=query, + headers=headers, + body=body, + json_getter=lambda: cls.jsonifier.loads(body), + form=form, + files=files, + context=req, + cookies=req.cookies) + + @classmethod + async def get_response(cls, response, mimetype=None, request=None): + """Get response. + This method is used in the lifecycle decorators + + :type response: aiohttp.web.StreamResponse | (Any,) | (Any, int) | (Any, dict) | (Any, int, dict) + :rtype: aiohttp.web.Response + """ + while asyncio.iscoroutine(response): + response = await response + + url = str(request.url) if request else '' + + return cls._get_response(response, mimetype=mimetype, extra_context={"url": url}) + + @classmethod + def _is_framework_response(cls, response): + """ Return True if `response` is a framework response class """ + return isinstance(response, web.StreamResponse) + + @classmethod + def _framework_to_firetail_response(cls, response, mimetype): + """ Cast framework response class to FiretailResponse used for schema validation """ + body = None + if hasattr(response, "body"): # StreamResponse and FileResponse don't have body + body = response.body + return FiretailResponse( + status_code=response.status, + mimetype=mimetype, + content_type=response.content_type, + headers=response.headers, + body=body + ) + + @classmethod + def _firetail_to_framework_response(cls, response, mimetype, extra_context=None): + """ Cast FiretailResponse to framework response class """ + return cls._build_response( + mimetype=response.mimetype or mimetype, + status_code=response.status_code, + content_type=response.content_type, + headers=response.headers, + data=response.body, + extra_context=extra_context, + ) + + @classmethod + def _build_response(cls, data, mimetype, content_type=None, headers=None, status_code=None, extra_context=None): + if cls._is_framework_response(data): + raise TypeError("Cannot return web.StreamResponse in tuple. Only raw data can be returned in tuple.") + + data, status_code, serialized_mimetype = cls._prepare_body_and_status_code(data=data, + mimetype=mimetype, + status_code=status_code, + extra_context=extra_context) + + if isinstance(data, str): + text = data + body = None + else: + text = None + body = data + + content_type = content_type or mimetype or serialized_mimetype + return web.Response(body=body, text=text, headers=headers, status=status_code, content_type=content_type) + + @classmethod + def _set_jsonifier(cls): + cls.jsonifier = Jsonifier(cls=JSONEncoder) + + +class _HttpNotFoundError(HTTPNotFound): + def __init__(self): + self.name = 'Not Found' + self.description = ( + 'The requested URL was not found on the server. ' + 'If you entered the URL manually please check your spelling and ' + 'try again.' + ) + self.code = type(self).status_code + self.empty_body = True + + HTTPNotFound.__init__(self, reason=self.name) diff --git a/firetail/apis/flask_api.py b/firetail/apis/flask_api.py index 7fd6441..c2d252e 100644 --- a/firetail/apis/flask_api.py +++ b/firetail/apis/flask_api.py @@ -1,200 +1,336 @@ -""" -This module defines a Flask Firetail API which implements translations between Flask and -Firetail requests / responses. -""" - -import logging -import warnings -from typing import Any - -import flask -import werkzeug.exceptions -from werkzeug.local import LocalProxy - -from firetail.apis import flask_utils -from firetail.apis.abstract import AbstractAPI -from firetail.handlers import AuthErrorHandler -from firetail.jsonifier import Jsonifier -from firetail.lifecycle import FiretailRequest, FiretailResponse -from firetail.security import FlaskSecurityHandlerFactory -from firetail.utils import is_json_mimetype - -logger = logging.getLogger('firetail.apis.flask_api') - - -class FlaskApi(AbstractAPI): - - @staticmethod - def make_security_handler_factory(pass_context_arg_name): - """ Create default SecurityHandlerFactory to create all security check handlers """ - return FlaskSecurityHandlerFactory(pass_context_arg_name) - - def _set_base_path(self, base_path): - super()._set_base_path(base_path) - self._set_blueprint() - - def _set_blueprint(self): - logger.debug('Creating API blueprint: %s', self.base_path) - endpoint = flask_utils.flaskify_endpoint(self.base_path) - self.blueprint = flask.Blueprint(endpoint, __name__, url_prefix=self.base_path, - template_folder=str(self.options.openapi_console_ui_from_dir)) - - def add_auth_on_not_found(self, security, security_definitions): - """ - Adds a 404 error handler to authenticate and only expose the 404 status if the security validation pass. - """ - logger.debug('Adding path not found authentication') - not_found_error = AuthErrorHandler(self, werkzeug.exceptions.NotFound(), security=security, - security_definitions=security_definitions) - endpoint_name = f"{self.blueprint.name}_not_found" - self.blueprint.add_url_rule( - '/', endpoint_name, not_found_error.function) - - def _add_operation_internal(self, method, path, operation): - operation_id = operation.operation_id - logger.debug('... Adding %s -> %s', method.upper(), operation_id, - extra=vars(operation)) - - flask_path = flask_utils.flaskify_path( - path, operation.get_path_parameter_types()) - endpoint_name = flask_utils.flaskify_endpoint(operation.operation_id, - operation.randomize_endpoint) - function = operation.function - self.blueprint.add_url_rule( - flask_path, endpoint_name, function, methods=[method]) - - @classmethod - def get_response(cls, response, mimetype=None, request=None): - """Gets FiretailResponse instance for the operation handler - result. Status Code and Headers for response. If only body - data is returned by the endpoint function, then the status - code will be set to 200 and no headers will be added. - - If the returned object is a flask.Response then it will just - pass the information needed to recreate it. - - :type response: flask.Response | (flask.Response,) | (flask.Response, int) | (flask.Response, dict) | (flask.Response, int, dict) - :rtype: FiretailResponse - """ - return cls._get_response(response, mimetype=mimetype, extra_context={"url": flask.request.url}) - - @classmethod - def _is_framework_response(cls, response): - """ Return True if provided response is a framework type """ - return flask_utils.is_flask_response(response) - - @classmethod - def _framework_to_firetail_response(cls, response, mimetype): - """ Cast framework response class to FiretailResponse used for schema validation """ - return FiretailResponse( - status_code=response.status_code, - mimetype=response.mimetype, - content_type=response.content_type, - headers=response.headers, - body=response.get_data() if not response.direct_passthrough else None, - is_streamed=response.is_streamed - ) - - @classmethod - def _firetail_to_framework_response(cls, response, mimetype, extra_context=None): - """ Cast FiretailResponse to framework response class """ - flask_response = cls._build_response( - mimetype=response.mimetype or mimetype, - content_type=response.content_type, - headers=response.headers, - status_code=response.status_code, - data=response.body, - extra_context=extra_context, - ) - - return flask_response - - @classmethod - def _build_response(cls, mimetype, content_type=None, headers=None, status_code=None, data=None, extra_context=None): - if cls._is_framework_response(data): - return flask.current_app.make_response((data, status_code, headers)) - - data, status_code, serialized_mimetype = cls._prepare_body_and_status_code( - data=data, mimetype=mimetype, status_code=status_code, extra_context=extra_context) - - kwargs = { - 'mimetype': mimetype or serialized_mimetype, - 'content_type': content_type, - 'headers': headers, - 'response': data, - 'status': status_code - } - kwargs = {k: v for k, v in kwargs.items() if v is not None} - return flask.current_app.response_class(**kwargs) - - @classmethod - def _serialize_data(cls, data, mimetype): - if (isinstance(mimetype, str) and is_json_mimetype(mimetype)): - body = cls.jsonifier.dumps(data) - elif not (isinstance(data, bytes) or isinstance(data, str)): - warnings.warn( - "Implicit (flask) JSON serialization will change in the next major version. " - "This is triggered because a response body is being serialized as JSON " - "even though the mimetype is not a JSON type. " - "This will be replaced by something that is mimetype-specific and may " - "raise an error instead of silently converting everything to JSON. " - "Please make sure to specify media/mime types in your specs.", - FutureWarning # a Deprecation targeted at application users. - ) - body = cls.jsonifier.dumps(data) - else: - body = data - - return body, mimetype - - @classmethod - def get_request(cls, *args, **params): - # type: (*Any, **Any) -> FiretailRequest - """Gets FiretailRequest instance for the operation handler - result. Status Code and Headers for response. If only body - data is returned by the endpoint function, then the status - code will be set to 200 and no headers will be added. - - If the returned object is a flask.Response then it will just - pass the information needed to recreate it. - - :rtype: FiretailRequest - """ - context_dict = {} - setattr(flask._request_ctx_stack.top, - 'firetail_context', context_dict) - flask_request = flask.request - request = FiretailRequest( - flask_request.url, - flask_request.method, - headers=flask_request.headers, - form=flask_request.form, - query=flask_request.args, - body=flask_request.get_data(), - json_getter=lambda: flask_request.get_json(silent=True), - files=flask_request.files, - path_params=params, - context=context_dict, - cookies=flask_request.cookies, - ) - logger.debug('Getting data and status code', - extra={ - 'data': request.body, - 'data_type': type(request.body), - 'url': request.url - }) - return request - - @classmethod - def _set_jsonifier(cls): - """ - Use Flask specific JSON loader - """ - cls.jsonifier = Jsonifier(flask.json, indent=2) - - -def _get_context(): - return getattr(flask._request_ctx_stack.top, 'firetail_context') - - -context = LocalProxy(_get_context) +""" +This module defines a Flask Firetail API which implements translations between Flask and +Firetail requests / responses. +""" + +import logging +import pathlib +import warnings +from typing import Any + +import flask +import werkzeug.exceptions +from werkzeug.local import LocalProxy + +from firetail.apis import flask_utils +from firetail.apis.abstract import AbstractAPI +from firetail.handlers import AuthErrorHandler +from firetail.jsonifier import Jsonifier +from firetail.lifecycle import FiretailRequest, FiretailResponse +from firetail.security import FlaskSecurityHandlerFactory +from firetail.utils import is_json_mimetype, yamldumper + +logger = logging.getLogger('firetail.apis.flask_api') + + +class FlaskApi(AbstractAPI): + + @staticmethod + def make_security_handler_factory(pass_context_arg_name): + """ Create default SecurityHandlerFactory to create all security check handlers """ + return FlaskSecurityHandlerFactory(pass_context_arg_name) + + def _set_base_path(self, base_path): + super()._set_base_path(base_path) + self._set_blueprint() + + def _set_blueprint(self): + logger.debug('Creating API blueprint: %s', self.base_path) + endpoint = flask_utils.flaskify_endpoint(self.base_path) + self.blueprint = flask.Blueprint(endpoint, __name__, url_prefix=self.base_path, + template_folder=str(self.options.openapi_console_ui_from_dir)) + + def add_openapi_json(self): + """ + Adds spec json to {base_path}/swagger.json + or {base_path}/openapi.json (for oas3) + """ + logger.debug('Adding spec json: %s/%s', self.base_path, + self.options.openapi_spec_path) + endpoint_name = f"{self.blueprint.name}_openapi_json" + + self.blueprint.add_url_rule(self.options.openapi_spec_path, + endpoint_name, + self._handlers.get_json_spec) + + def add_openapi_yaml(self): + """ + Adds spec yaml to {base_path}/swagger.yaml + or {base_path}/openapi.yaml (for oas3) + """ + if not self.options.openapi_spec_path.endswith("json"): + return + + openapi_spec_path_yaml = \ + self.options.openapi_spec_path[:-len("json")] + "yaml" + logger.debug('Adding spec yaml: %s/%s', self.base_path, + openapi_spec_path_yaml) + endpoint_name = f"{self.blueprint.name}_openapi_yaml" + self.blueprint.add_url_rule( + openapi_spec_path_yaml, + endpoint_name, + self._handlers.get_yaml_spec + ) + + def add_swagger_ui(self): + """ + Adds swagger ui to {base_path}/ui/ + """ + console_ui_path = self.options.openapi_console_ui_path.strip('/') + logger.debug('Adding swagger-ui: %s/%s/', + self.base_path, + console_ui_path) + + if self.options.openapi_console_ui_config is not None: + config_endpoint_name = f"{self.blueprint.name}_swagger_ui_config" + config_file_url = '/{console_ui_path}/swagger-ui-config.json'.format( + console_ui_path=console_ui_path) + + self.blueprint.add_url_rule(config_file_url, + config_endpoint_name, + lambda: flask.jsonify(self.options.openapi_console_ui_config)) + + static_endpoint_name = f"{self.blueprint.name}_swagger_ui_static" + static_files_url = '/{console_ui_path}/'.format( + console_ui_path=console_ui_path) + + self.blueprint.add_url_rule(static_files_url, + static_endpoint_name, + self._handlers.console_ui_static_files) + + index_endpoint_name = f"{self.blueprint.name}_swagger_ui_index" + console_ui_url = '/{console_ui_path}/'.format( + console_ui_path=console_ui_path) + + self.blueprint.add_url_rule(console_ui_url, + index_endpoint_name, + self._handlers.console_ui_home) + + def add_auth_on_not_found(self, security, security_definitions): + """ + Adds a 404 error handler to authenticate and only expose the 404 status if the security validation pass. + """ + logger.debug('Adding path not found authentication') + not_found_error = AuthErrorHandler(self, werkzeug.exceptions.NotFound(), security=security, + security_definitions=security_definitions) + endpoint_name = f"{self.blueprint.name}_not_found" + self.blueprint.add_url_rule('/', endpoint_name, not_found_error.function) + + def _add_operation_internal(self, method, path, operation): + operation_id = operation.operation_id + logger.debug('... Adding %s -> %s', method.upper(), operation_id, + extra=vars(operation)) + + flask_path = flask_utils.flaskify_path(path, operation.get_path_parameter_types()) + endpoint_name = flask_utils.flaskify_endpoint(operation.operation_id, + operation.randomize_endpoint) + function = operation.function + self.blueprint.add_url_rule(flask_path, endpoint_name, function, methods=[method]) + + @property + def _handlers(self): + # type: () -> InternalHandlers + if not hasattr(self, '_internal_handlers'): + self._internal_handlers = InternalHandlers(self.base_path, self.options, self.specification) + return self._internal_handlers + + @classmethod + def get_response(cls, response, mimetype=None, request=None): + """Gets FiretailResponse instance for the operation handler + result. Status Code and Headers for response. If only body + data is returned by the endpoint function, then the status + code will be set to 200 and no headers will be added. + + If the returned object is a flask.Response then it will just + pass the information needed to recreate it. + + :type response: flask.Response | (flask.Response,) | (flask.Response, int) | (flask.Response, dict) | (flask.Response, int, dict) + :rtype: FiretailResponse + """ + return cls._get_response(response, mimetype=mimetype, extra_context={"url": flask.request.url}) + + @classmethod + def _is_framework_response(cls, response): + """ Return True if provided response is a framework type """ + return flask_utils.is_flask_response(response) + + @classmethod + def _framework_to_firetail_response(cls, response, mimetype): + """ Cast framework response class to FiretailResponse used for schema validation """ + return FiretailResponse( + status_code=response.status_code, + mimetype=response.mimetype, + content_type=response.content_type, + headers=response.headers, + body=response.get_data() if not response.direct_passthrough else None, + is_streamed=response.is_streamed + ) + + @classmethod + def _firetail_to_framework_response(cls, response, mimetype, extra_context=None): + """ Cast FiretailResponse to framework response class """ + flask_response = cls._build_response( + mimetype=response.mimetype or mimetype, + content_type=response.content_type, + headers=response.headers, + status_code=response.status_code, + data=response.body, + extra_context=extra_context, + ) + + return flask_response + + @classmethod + def _build_response(cls, mimetype, content_type=None, headers=None, status_code=None, data=None, extra_context=None): + if cls._is_framework_response(data): + return flask.current_app.make_response((data, status_code, headers)) + + data, status_code, serialized_mimetype = cls._prepare_body_and_status_code(data=data, + mimetype=mimetype, + status_code=status_code, + extra_context=extra_context) + + kwargs = { + 'mimetype': mimetype or serialized_mimetype, + 'content_type': content_type, + 'headers': headers, + 'response': data, + 'status': status_code + } + kwargs = {k: v for k, v in kwargs.items() if v is not None} + return flask.current_app.response_class(**kwargs) + + @classmethod + def _serialize_data(cls, data, mimetype): + # TODO: harmonize flask and aiohttp serialization when mimetype=None or mimetype is not JSON + # (cases where it might not make sense to jsonify the data) + if (isinstance(mimetype, str) and is_json_mimetype(mimetype)): + body = cls.jsonifier.dumps(data) + elif not (isinstance(data, bytes) or isinstance(data, str)): + warnings.warn( + "Implicit (flask) JSON serialization will change in the next major version. " + "This is triggered because a response body is being serialized as JSON " + "even though the mimetype is not a JSON type. " + "This will be replaced by something that is mimetype-specific and may " + "raise an error instead of silently converting everything to JSON. " + "Please make sure to specify media/mime types in your specs.", + FutureWarning # a Deprecation targeted at application users. + ) + body = cls.jsonifier.dumps(data) + else: + body = data + + return body, mimetype + + @classmethod + def get_request(cls, *args, **params): + # type: (*Any, **Any) -> FiretailRequest + """Gets FiretailRequest instance for the operation handler + result. Status Code and Headers for response. If only body + data is returned by the endpoint function, then the status + code will be set to 200 and no headers will be added. + + If the returned object is a flask.Response then it will just + pass the information needed to recreate it. + + :rtype: FiretailRequest + """ + context_dict = {} + setattr(flask._request_ctx_stack.top, 'firetail_context', context_dict) + flask_request = flask.request + request = FiretailRequest( + flask_request.url, + flask_request.method, + headers=flask_request.headers, + form=flask_request.form, + query=flask_request.args, + body=flask_request.get_data(), + json_getter=lambda: flask_request.get_json(silent=True), + files=flask_request.files, + path_params=params, + context=context_dict, + cookies=flask_request.cookies, + ) + logger.debug('Getting data and status code', + extra={ + 'data': request.body, + 'data_type': type(request.body), + 'url': request.url + }) + return request + + @classmethod + def _set_jsonifier(cls): + """ + Use Flask specific JSON loader + """ + cls.jsonifier = Jsonifier(flask.json, indent=2) + + +def _get_context(): + return getattr(flask._request_ctx_stack.top, 'firetail_context') + + +context = LocalProxy(_get_context) + + +class InternalHandlers: + """ + Flask handlers for internally registered endpoints. + """ + + def __init__(self, base_path, options, specification): + self.base_path = base_path + self.options = options + self.specification = specification + + def console_ui_home(self): + """ + Home page of the OpenAPI Console UI. + + :return: + """ + openapi_json_route_name = "{blueprint}.{prefix}_openapi_json" + escaped = flask_utils.flaskify_endpoint(self.base_path) + openapi_json_route_name = openapi_json_route_name.format( + blueprint=escaped, + prefix=escaped + ) + template_variables = { + 'openapi_spec_url': flask.url_for(openapi_json_route_name), + **self.options.openapi_console_ui_index_template_variables, + } + if self.options.openapi_console_ui_config is not None: + template_variables['configUrl'] = 'swagger-ui-config.json' + + # Use `render_template_string` instead of `render_template` to circumvent the flask + # template lookup mechanism and explicitly render the template of the current blueprint. + # https://github.com/zalando/firetail/issues/1289#issuecomment-884105076 + template_dir = pathlib.Path(self.options.openapi_console_ui_from_dir) + index_path = template_dir / 'index.j2' + return flask.render_template_string(index_path.read_text(), **template_variables) + + def console_ui_static_files(self, filename): + """ + Servers the static files for the OpenAPI Console UI. + + :param filename: Requested file contents. + :return: + """ + # convert PosixPath to str + static_dir = str(self.options.openapi_console_ui_from_dir) + return flask.send_from_directory(static_dir, filename) + + def get_json_spec(self): + return flask.jsonify(self._spec_for_prefix()) + + def get_yaml_spec(self): + return yamldumper(self._spec_for_prefix()), 200, {"Content-Type": "text/yaml"} + + def _spec_for_prefix(self): + """ + Modify base_path in the spec based on incoming url + This fixes problems with reverse proxies changing the path. + """ + base_path = flask.url_for(flask.request.endpoint).rsplit("/", 1)[0] + return self.specification.with_base_path(base_path).raw diff --git a/firetail/apis/flask_utils.py b/firetail/apis/flask_utils.py index 5c2d667..e2944f0 100644 --- a/firetail/apis/flask_utils.py +++ b/firetail/apis/flask_utils.py @@ -1,83 +1,83 @@ -""" -This module defines utility functions related to the Flask framework. -""" - -import functools -import random -import re -import string - -import flask -import werkzeug.wrappers - -PATH_PARAMETER = re.compile(r'\{([^}]*)\}') - -# map Swagger type to flask path converter -# see http://flask.pocoo.org/docs/0.10/api/#url-route-registrations -PATH_PARAMETER_CONVERTERS = { - 'integer': 'int', - 'number': 'float', - 'path': 'path' -} - - -def flaskify_endpoint(identifier, randomize=None): - """ - Converts the provided identifier in a valid flask endpoint name - - :type identifier: str - :param randomize: If specified, add this many random characters (upper case - and digits) to the endpoint name, separated by a pipe character. - :type randomize: int | None - :rtype: str - - """ - result = identifier.replace('.', '_') - if randomize is None: - return result - - chars = string.ascii_uppercase + string.digits - return "{result}|{random_string}".format( - result=result, - random_string=''.join(random.SystemRandom().choice(chars) for _ in range(randomize))) - - -def convert_path_parameter(match, types): - name = match.group(1) - swagger_type = types.get(name) - converter = PATH_PARAMETER_CONVERTERS.get(swagger_type) - return '<{}{}{}>'.format( - converter or '', ':' if converter else '', name.replace('-', '_') - ) - - -def flaskify_path(swagger_path, types=None): - """ - Convert swagger path templates to flask path templates - - :type swagger_path: str - :type types: dict - :rtype: str - - >>> flaskify_path('/foo-bar/{my-param}') - '/foo-bar/' - - >>> flaskify_path('/foo/{someint}', {'someint': 'int'}) - '/foo/' - """ - if types is None: - types = {} - convert_match = functools.partial(convert_path_parameter, types=types) - return PATH_PARAMETER.sub(convert_match, swagger_path) - - -def is_flask_response(obj: object) -> bool: - """ - Verifies if obj is a default Flask response instance. - - >>> is_flask_response(redirect('http://example.com/')) - True - >>> is_flask_response(flask.Response()) - True - """ - return isinstance(obj, flask.Response) or isinstance(obj, werkzeug.wrappers.Response) +""" +This module defines utility functions related to the Flask framework. +""" + +import functools +import random +import re +import string + +import flask +import werkzeug.wrappers + +PATH_PARAMETER = re.compile(r'\{([^}]*)\}') + +# map Swagger type to flask path converter +# see http://flask.pocoo.org/docs/0.10/api/#url-route-registrations +PATH_PARAMETER_CONVERTERS = { + 'integer': 'int', + 'number': 'float', + 'path': 'path' +} + + +def flaskify_endpoint(identifier, randomize=None): + """ + Converts the provided identifier in a valid flask endpoint name + + :type identifier: str + :param randomize: If specified, add this many random characters (upper case + and digits) to the endpoint name, separated by a pipe character. + :type randomize: int | None + :rtype: str + + """ + result = identifier.replace('.', '_') + if randomize is None: + return result + + chars = string.ascii_uppercase + string.digits + return "{result}|{random_string}".format( + result=result, + random_string=''.join(random.SystemRandom().choice(chars) for _ in range(randomize))) + + +def convert_path_parameter(match, types): + name = match.group(1) + swagger_type = types.get(name) + converter = PATH_PARAMETER_CONVERTERS.get(swagger_type) + return '<{}{}{}>'.format( + converter or '', ':' if converter else '', name.replace('-', '_') + ) + + +def flaskify_path(swagger_path, types=None): + """ + Convert swagger path templates to flask path templates + + :type swagger_path: str + :type types: dict + :rtype: str + + >>> flaskify_path('/foo-bar/{my-param}') + '/foo-bar/' + + >>> flaskify_path('/foo/{someint}', {'someint': 'int'}) + '/foo/' + """ + if types is None: + types = {} + convert_match = functools.partial(convert_path_parameter, types=types) + return PATH_PARAMETER.sub(convert_match, swagger_path) + + +def is_flask_response(obj: object) -> bool: + """ + Verifies if obj is a default Flask response instance. + + >>> is_flask_response(redirect('http://example.com/')) + True + >>> is_flask_response(flask.Response()) + True + """ + return isinstance(obj, flask.Response) or isinstance(obj, werkzeug.wrappers.Response) diff --git a/firetail/apps/__init__.py b/firetail/apps/__init__.py index 6d01830..a8afc06 100644 --- a/firetail/apps/__init__.py +++ b/firetail/apps/__init__.py @@ -1,6 +1,6 @@ -""" -This module defines Firetail applications. A Firetail App wraps a specific framework application -and exposes a standardized interface for users to create and configure their Firetail application. -""" - -from .abstract import AbstractApp # NOQA +""" +This module defines Firetail applications. A Firetail App wraps a specific framework application +and exposes a standardized interface for users to create and configure their Firetail application. +""" + +from .abstract import AbstractApp # NOQA diff --git a/firetail/apps/abstract.py b/firetail/apps/abstract.py index 1040d92..f617987 100644 --- a/firetail/apps/abstract.py +++ b/firetail/apps/abstract.py @@ -1,271 +1,254 @@ -""" -This module defines an AbstractApp, which defines a standardized user interface for a Firetail -application. -""" - -import abc -import logging -import pathlib - -from ..options import FiretailOptions -from ..resolver import Resolver - -logger = logging.getLogger('firetail.app') - - -class AbstractApp(metaclass=abc.ABCMeta): - def __init__(self, import_name, api_cls, port=None, specification_dir='', - host=None, server=None, server_args=None, arguments=None, auth_all_paths=False, debug=None, - resolver=None, options=None, skip_error_handlers=False): - """ - :param import_name: the name of the application package - :type import_name: str - :param host: the host interface to bind on. - :type host: str - :param port: port to listen to - :type port: int - :param specification_dir: directory where to look for specifications - :type specification_dir: pathlib.Path | str - :param server: which wsgi server to use - :type server: str | None - :param server_args: dictionary of arguments which are then passed to appropriate http server (Flask or aio_http) - :type server_args: dict | None - :param arguments: arguments to replace on the specification - :type arguments: dict | None - :param auth_all_paths: whether to authenticate not defined paths - :type auth_all_paths: bool - :param debug: include debugging information - :type debug: bool - :param resolver: Callable that maps operationID to a function - """ - self.port = port - self.host = host - self.debug = debug - self.resolver = resolver - self.import_name = import_name - self.arguments = arguments or {} - self.api_cls = api_cls - self.resolver_error = None - - # Options - self.auth_all_paths = auth_all_paths - - self.options = FiretailOptions(options) - - self.server = server - self.server_args = dict() if server_args is None else server_args - self.app = self.create_app() - self.middleware = self._apply_middleware() - - # we get our application root path to avoid duplicating logic - self.root_path = self.get_root_path() - logger.debug('Root Path: %s', self.root_path) - - # Ensure specification dir is a Path - specification_dir = pathlib.Path(specification_dir) - if specification_dir.is_absolute(): - self.specification_dir = specification_dir - else: - self.specification_dir = self.root_path / specification_dir - - logger.debug('Specification directory: %s', self.specification_dir) - - if not skip_error_handlers: - logger.debug('Setting error handlers') - self.set_errors_handlers() - - @abc.abstractmethod - def create_app(self): - """ - Creates the user framework application - """ - - @abc.abstractmethod - def _apply_middleware(self): - """ - Apply middleware to application - """ - - @abc.abstractmethod - def get_root_path(self): - """ - Gets the root path of the user framework application - """ - - @abc.abstractmethod - def set_errors_handlers(self): - """ - Sets all errors handlers of the user framework application - """ - - def add_api(self, specification, base_path=None, arguments=None, - auth_all_paths=None, validate_responses=False, - strict_validation=False, resolver=None, resolver_error=None, - pythonic_params=False, pass_context_arg_name=None, options=None, - validator_map=None): - """ - Adds an API to the application based on a swagger file or API dict - - :param specification: swagger file with the specification | specification dict - :type specification: pathlib.Path or str or dict - :param base_path: base path where to add this api - :type base_path: str | None - :param arguments: api version specific arguments to replace on the specification - :type arguments: dict | None - :param auth_all_paths: whether to authenticate not defined paths - :type auth_all_paths: bool - :param validate_responses: True enables validation. Validation errors generate HTTP 500 responses. - :type validate_responses: bool - :param strict_validation: True enables validation on invalid request parameters - :type strict_validation: bool - :param resolver: Operation resolver. - :type resolver: Resolver | types.FunctionType - :param resolver_error: If specified, turns ResolverError into error - responses with the given status code. - :type resolver_error: int | None - :param pythonic_params: When True CamelCase parameters are converted to snake_case - :type pythonic_params: bool - :param options: New style options dictionary. - :type options: dict | None - :param pass_context_arg_name: Name of argument in handler functions to pass request context to. - :type pass_context_arg_name: str | None - :param validator_map: map of validators - :type validator_map: dict - :rtype: AbstractAPI - """ - # Turn the resolver_error code into a handler object - self.resolver_error = resolver_error - resolver_error_handler = None - if self.resolver_error is not None: - resolver_error_handler = self._resolver_error_handler - - resolver = resolver or self.resolver - resolver = Resolver(resolver) if hasattr( - resolver, '__call__') else resolver - - auth_all_paths = auth_all_paths if auth_all_paths is not None else self.auth_all_paths - # TODO test if base_path starts with an / (if not none) - arguments = arguments or dict() - # copy global arguments and update with api specific - arguments = dict(self.arguments, **arguments) - - if isinstance(specification, dict): - specification = specification - else: - specification = self.specification_dir / specification - - api_options = self.options.extend(options) - - self.middleware.add_api( - specification, - base_path=base_path, - arguments=arguments, - resolver=resolver, - resolver_error_handler=resolver_error_handler, - validate_responses=validate_responses, - strict_validation=strict_validation, - auth_all_paths=auth_all_paths, - debug=self.debug, - validator_map=validator_map, - pythonic_params=pythonic_params, - pass_context_arg_name=pass_context_arg_name, - options=api_options.as_dict() - ) - - api = self.api_cls(specification, - base_path=base_path, - arguments=arguments, - resolver=resolver, - resolver_error_handler=resolver_error_handler, - validate_responses=validate_responses, - strict_validation=strict_validation, - auth_all_paths=auth_all_paths, - debug=self.debug, - validator_map=validator_map, - pythonic_params=pythonic_params, - pass_context_arg_name=pass_context_arg_name, - options=api_options.as_dict()) - return api - - def _resolver_error_handler(self, *args, **kwargs): - from firetail.handlers import ResolverErrorHandler - return ResolverErrorHandler(self.api_cls, self.resolver_error, *args, **kwargs) - - def add_url_rule(self, rule, endpoint=None, view_func=None, **options): - """ - Connects a URL rule. Works exactly like the `route` decorator. If a view_func is provided it will be - registered with the endpoint. - - Basically this example:: - - @app.route('/') - def index(): - pass - - Is equivalent to the following:: - - def index(): - pass - app.add_url_rule('/', 'index', index) - - If the view_func is not provided you will need to connect the endpoint to a view function like so:: - - app.view_functions['index'] = index - - Internally`route` invokes `add_url_rule` so if you want to customize the behavior via subclassing you only need - to change this method. - - :param rule: the URL rule as string - :type rule: str - :param endpoint: the endpoint for the registered URL rule. Flask itself assumes the name of the view function as - endpoint - :type endpoint: str - :param view_func: the function to call when serving a request to the provided endpoint - :type view_func: types.FunctionType - :param options: the options to be forwarded to the underlying `werkzeug.routing.Rule` object. A change - to Werkzeug is handling of method options. methods is a list of methods this rule should be - limited to (`GET`, `POST` etc.). By default a rule just listens for `GET` (and implicitly - `HEAD`). - """ - log_details = {'endpoint': endpoint, 'view_func': view_func.__name__} - log_details.update(options) - logger.debug('Adding %s', rule, extra=log_details) - self.app.add_url_rule(rule, endpoint, view_func, **options) - - def route(self, rule, **options): - """ - A decorator that is used to register a view function for a - given URL rule. This does the same thing as `add_url_rule` - but is intended for decorator usage:: - - @app.route('/') - def index(): - return 'Hello World' - - :param rule: the URL rule as string - :type rule: str - :param endpoint: the endpoint for the registered URL rule. Flask - itself assumes the name of the view function as - endpoint - :param options: the options to be forwarded to the underlying `werkzeug.routing.Rule` object. A change - to Werkzeug is handling of method options. methods is a list of methods this rule should be - limited to (`GET`, `POST` etc.). By default a rule just listens for `GET` (and implicitly - `HEAD`). - """ - logger.debug('Adding %s with decorator', rule, extra=options) - return self.app.route(rule, **options) - - @abc.abstractmethod - def run(self, port=None, server=None, debug=None, host=None, **options): # pragma: no cover - """ - Runs the application on a local development server. - :param host: the host interface to bind on. - :type host: str - :param port: port to listen to - :type port: int - :param server: which wsgi server to use - :type server: str | None - :param debug: include debugging information - :type debug: bool - :param options: options to be forwarded to the underlying server - """ +""" +This module defines an AbstractApp, which defines a standardized user interface for a Firetail +application. +""" + +import abc +import logging +import pathlib + +from ..options import FiretailOptions +from ..resolver import Resolver + +logger = logging.getLogger('firetail.app') + + +class AbstractApp(metaclass=abc.ABCMeta): + def __init__(self, import_name, api_cls, port=None, specification_dir='', + host=None, server=None, server_args=None, arguments=None, auth_all_paths=False, debug=None, + resolver=None, options=None, skip_error_handlers=False): + """ + :param import_name: the name of the application package + :type import_name: str + :param host: the host interface to bind on. + :type host: str + :param port: port to listen to + :type port: int + :param specification_dir: directory where to look for specifications + :type specification_dir: pathlib.Path | str + :param server: which wsgi server to use + :type server: str | None + :param server_args: dictionary of arguments which are then passed to appropriate http server (Flask or aio_http) + :type server_args: dict | None + :param arguments: arguments to replace on the specification + :type arguments: dict | None + :param auth_all_paths: whether to authenticate not defined paths + :type auth_all_paths: bool + :param debug: include debugging information + :type debug: bool + :param resolver: Callable that maps operationID to a function + """ + self.port = port + self.host = host + self.debug = debug + self.resolver = resolver + self.import_name = import_name + self.arguments = arguments or {} + self.api_cls = api_cls + self.resolver_error = None + + # Options + self.auth_all_paths = auth_all_paths + + self.options = FiretailOptions(options) + + self.server = server + self.server_args = dict() if server_args is None else server_args + self.app = self.create_app() + + # we get our application root path to avoid duplicating logic + self.root_path = self.get_root_path() + logger.debug('Root Path: %s', self.root_path) + + specification_dir = pathlib.Path(specification_dir) # Ensure specification dir is a Path + if specification_dir.is_absolute(): + self.specification_dir = specification_dir + else: + self.specification_dir = self.root_path / specification_dir + + logger.debug('Specification directory: %s', self.specification_dir) + + if not skip_error_handlers: + logger.debug('Setting error handlers') + self.set_errors_handlers() + + @abc.abstractmethod + def create_app(self): + """ + Creates the user framework application + """ + + @abc.abstractmethod + def get_root_path(self): + """ + Gets the root path of the user framework application + """ + + @abc.abstractmethod + def set_errors_handlers(self): + """ + Sets all errors handlers of the user framework application + """ + + def add_api(self, specification, base_path=None, arguments=None, + auth_all_paths=None, validate_responses=False, + strict_validation=False, resolver=None, resolver_error=None, + pythonic_params=False, pass_context_arg_name=None, options=None, + validator_map=None): + """ + Adds an API to the application based on a swagger file or API dict + + :param specification: swagger file with the specification | specification dict + :type specification: pathlib.Path or str or dict + :param base_path: base path where to add this api + :type base_path: str | None + :param arguments: api version specific arguments to replace on the specification + :type arguments: dict | None + :param auth_all_paths: whether to authenticate not defined paths + :type auth_all_paths: bool + :param validate_responses: True enables validation. Validation errors generate HTTP 500 responses. + :type validate_responses: bool + :param strict_validation: True enables validation on invalid request parameters + :type strict_validation: bool + :param resolver: Operation resolver. + :type resolver: Resolver | types.FunctionType + :param resolver_error: If specified, turns ResolverError into error + responses with the given status code. + :type resolver_error: int | None + :param pythonic_params: When True CamelCase parameters are converted to snake_case + :type pythonic_params: bool + :param options: New style options dictionary. + :type options: dict | None + :param pass_context_arg_name: Name of argument in handler functions to pass request context to. + :type pass_context_arg_name: str | None + :param validator_map: map of validators + :type validator_map: dict + :rtype: AbstractAPI + """ + # Turn the resolver_error code into a handler object + self.resolver_error = resolver_error + resolver_error_handler = None + if self.resolver_error is not None: + resolver_error_handler = self._resolver_error_handler + + resolver = resolver or self.resolver + resolver = Resolver(resolver) if hasattr(resolver, '__call__') else resolver + + auth_all_paths = auth_all_paths if auth_all_paths is not None else self.auth_all_paths + # TODO test if base_path starts with an / (if not none) + arguments = arguments or dict() + arguments = dict(self.arguments, **arguments) # copy global arguments and update with api specific + + if isinstance(specification, dict): + specification = specification + else: + specification = self.specification_dir / specification + + api_options = self.options.extend(options) + + api = self.api_cls(specification, + base_path=base_path, + arguments=arguments, + resolver=resolver, + resolver_error_handler=resolver_error_handler, + validate_responses=validate_responses, + strict_validation=strict_validation, + auth_all_paths=auth_all_paths, + debug=self.debug, + validator_map=validator_map, + pythonic_params=pythonic_params, + pass_context_arg_name=pass_context_arg_name, + options=api_options.as_dict()) + return api + + def _resolver_error_handler(self, *args, **kwargs): + from firetail.handlers import ResolverErrorHandler + return ResolverErrorHandler(self.api_cls, self.resolver_error, *args, **kwargs) + + def add_url_rule(self, rule, endpoint=None, view_func=None, **options): + """ + Connects a URL rule. Works exactly like the `route` decorator. If a view_func is provided it will be + registered with the endpoint. + + Basically this example:: + + @app.route('/') + def index(): + pass + + Is equivalent to the following:: + + def index(): + pass + app.add_url_rule('/', 'index', index) + + If the view_func is not provided you will need to connect the endpoint to a view function like so:: + + app.view_functions['index'] = index + + Internally`route` invokes `add_url_rule` so if you want to customize the behavior via subclassing you only need + to change this method. + + :param rule: the URL rule as string + :type rule: str + :param endpoint: the endpoint for the registered URL rule. Flask itself assumes the name of the view function as + endpoint + :type endpoint: str + :param view_func: the function to call when serving a request to the provided endpoint + :type view_func: types.FunctionType + :param options: the options to be forwarded to the underlying `werkzeug.routing.Rule` object. A change + to Werkzeug is handling of method options. methods is a list of methods this rule should be + limited to (`GET`, `POST` etc.). By default a rule just listens for `GET` (and implicitly + `HEAD`). + """ + log_details = {'endpoint': endpoint, 'view_func': view_func.__name__} + log_details.update(options) + logger.debug('Adding %s', rule, extra=log_details) + self.app.add_url_rule(rule, endpoint, view_func, **options) + + def route(self, rule, **options): + """ + A decorator that is used to register a view function for a + given URL rule. This does the same thing as `add_url_rule` + but is intended for decorator usage:: + + @app.route('/') + def index(): + return 'Hello World' + + :param rule: the URL rule as string + :type rule: str + :param endpoint: the endpoint for the registered URL rule. Flask + itself assumes the name of the view function as + endpoint + :param options: the options to be forwarded to the underlying `werkzeug.routing.Rule` object. A change + to Werkzeug is handling of method options. methods is a list of methods this rule should be + limited to (`GET`, `POST` etc.). By default a rule just listens for `GET` (and implicitly + `HEAD`). + """ + logger.debug('Adding %s with decorator', rule, extra=options) + return self.app.route(rule, **options) + + @abc.abstractmethod + def run(self, port=None, server=None, debug=None, host=None, **options): # pragma: no cover + """ + Runs the application on a local development server. + :param host: the host interface to bind on. + :type host: str + :param port: port to listen to + :type port: int + :param server: which wsgi server to use + :type server: str | None + :param debug: include debugging information + :type debug: bool + :param options: options to be forwarded to the underlying server + """ + + def __call__(self, environ, start_response): # pragma: no cover + """ + Makes the class callable to be WSGI-compliant. As Flask is used to handle requests, + this is a passthrough-call to the Flask callable class. + This is an abstraction to avoid directly referencing the app attribute from outside the + class and protect it from unwanted modification. + """ + return self.app(environ, start_response) diff --git a/firetail/apps/aiohttp_app.py b/firetail/apps/aiohttp_app.py new file mode 100644 index 0000000..b81c4e1 --- /dev/null +++ b/firetail/apps/aiohttp_app.py @@ -0,0 +1,99 @@ +""" +This module defines an AioHttpApp, a Firetail application to wrap an AioHttp application. +""" + +import logging +import pathlib +import pkgutil +import sys + +from aiohttp import web + +from ..apis.aiohttp_api import AioHttpApi +from ..exceptions import FiretailException +from .abstract import AbstractApp + +logger = logging.getLogger('firetail.aiohttp_app') + + +class AioHttpApp(AbstractApp): + + def __init__(self, import_name, only_one_api=False, **kwargs): + super().__init__(import_name, AioHttpApi, server='aiohttp', **kwargs) + self._only_one_api = only_one_api + self._api_added = False + + def create_app(self): + return web.Application(**self.server_args) + + def get_root_path(self): + mod = sys.modules.get(self.import_name) + if mod is not None and hasattr(mod, '__file__'): + return pathlib.Path(mod.__file__).resolve().parent + + loader = pkgutil.get_loader(self.import_name) + filepath = None + + if hasattr(loader, 'get_filename'): + filepath = loader.get_filename(self.import_name) + + if filepath is None: + raise RuntimeError(f"Invalid import name '{self.import_name}'") + + return pathlib.Path(filepath).resolve().parent + + def set_errors_handlers(self): + pass + + def add_api(self, specification, **kwargs): + if self._only_one_api: + if self._api_added: + raise FiretailException( + "an api was already added, " + "create a new app with 'only_one_api=False' " + "to add more than one api" + ) + else: + self.app = self._get_api(specification, kwargs).subapp + self._api_added = True + return self.app + + api = self._get_api(specification, kwargs) + try: + self.app.add_subapp(api.base_path, api.subapp) + except ValueError: + raise FiretailException( + "aiohttp doesn't allow to set empty base_path ('/'), " + "use non-empty instead, e.g /api" + ) + + return api + + def _get_api(self, specification, kwargs): + return super().add_api(specification, **kwargs) + + def run(self, port=None, server=None, debug=None, host=None, **options): + if port is not None: + self.port = port + elif self.port is None: + self.port = 5000 + + self.server = server or self.server + self.host = host or self.host or '0.0.0.0' + + if debug is not None: + self.debug = debug + + logger.debug('Starting %s HTTP server..', self.server, extra=vars(self)) + + if self.server == 'aiohttp': + logger.info('Listening on %s:%s..', self.host, self.port) + + access_log = options.pop('access_log', None) + + if options.pop('use_default_access_log', None): + access_log = logger + + web.run_app(self.app, port=self.port, host=self.host, access_log=access_log, **options) + else: + raise Exception(f'Server {self.server} not recognized') diff --git a/firetail/apps/flask_app.py b/firetail/apps/flask_app.py index 253c237..a4c7788 100644 --- a/firetail/apps/flask_app.py +++ b/firetail/apps/flask_app.py @@ -1,207 +1,184 @@ -""" -This module defines a FlaskApp, a Firetail application to wrap a Flask application. -""" - -import datetime -import logging -import pathlib -from decimal import Decimal -from types import FunctionType # NOQA - -import a2wsgi -import flask -import werkzeug.exceptions -from flask import json, signals - -from ..apis.flask_api import FlaskApi -from ..exceptions import ProblemException -from ..middleware import FiretailMiddleware -from ..problem import problem -from .abstract import AbstractApp - -logger = logging.getLogger('firetail.app') - - -class FlaskApp(AbstractApp): - def __init__(self, import_name, server='flask', extra_files=None, **kwargs): - """ - :param extra_files: additional files to be watched by the reloader, defaults to the swagger specs of added apis - :type extra_files: list[str | pathlib.Path], optional - - See :class:`~firetail.AbstractApp` for additional parameters. - """ - self.extra_files = extra_files or [] - - super().__init__(import_name, FlaskApi, server=server, **kwargs) - - def create_app(self): - app = flask.Flask(self.import_name, **self.server_args) - app.json_encoder = FlaskJSONEncoder - app.url_map.converters['float'] = NumberConverter - app.url_map.converters['int'] = IntegerConverter - return app - - def _apply_middleware(self): - middlewares = [*FiretailMiddleware.default_middlewares, - a2wsgi.WSGIMiddleware] - middleware = FiretailMiddleware( - self.app.wsgi_app, middlewares=middlewares) - - # Wrap with ASGI to WSGI middleware for usage with development server and test client - self.app.wsgi_app = a2wsgi.ASGIMiddleware(middleware) - - return middleware - - def get_root_path(self): - return pathlib.Path(self.app.root_path) - - def set_errors_handlers(self): - for error_code in werkzeug.exceptions.default_exceptions: - self.add_error_handler(error_code, self.common_error_handler) - - self.add_error_handler(ProblemException, self.common_error_handler) - - def common_error_handler(self, exception): - """ - :type exception: Exception - """ - signals.got_request_exception.send(self.app, exception=exception) - if isinstance(exception, ProblemException): - response = problem( - status=exception.status, title=exception.title, detail=exception.detail, - type=exception.type, instance=exception.instance, headers=exception.headers, - ext=exception.ext) - else: - if not isinstance(exception, werkzeug.exceptions.HTTPException): - exception = werkzeug.exceptions.InternalServerError() - - response = problem(title=exception.name, - detail=exception.description, - status=exception.code, - headers=exception.get_headers()) - - return FlaskApi.get_response(response) - - def add_api(self, specification, **kwargs): - api = super().add_api(specification, **kwargs) - self.app.register_blueprint(api.blueprint) - if isinstance(specification, (str, pathlib.Path)): - self.extra_files.append(self.specification_dir / specification) - return api - - def add_error_handler(self, error_code, function): - # type: (int, FunctionType) -> None - self.app.register_error_handler(error_code, function) - - def run(self, - port=None, - server=None, - debug=None, - host=None, - extra_files=None, - **options): # pragma: no cover - """ - Runs the application on a local development server. - - :param host: the host interface to bind on. - :type host: str - :param port: port to listen to - :type port: int - :param server: which wsgi server to use - :type server: str | None - :param debug: include debugging information - :type debug: bool - :param extra_files: additional files to be watched by the reloader. - :type extra_files: Iterable[str | pathlib.Path] - :param options: options to be forwarded to the underlying server - """ - # this functions is not covered in unit tests because we would effectively testing the mocks - - # overwrite constructor parameter - if port is not None: - self.port = port - elif self.port is None: - self.port = 5000 - - self.host = host or self.host or '0.0.0.0' - - if server is not None: - self.server = server - - if debug is not None: - self.debug = debug - - if extra_files is not None: - self.extra_files.extend(extra_files) - - logger.debug('Starting %s HTTP server..', - self.server, extra=vars(self)) - if self.server == 'flask': - self.app.run(self.host, port=self.port, debug=self.debug, - extra_files=self.extra_files, **options) - elif self.server == 'tornado': - try: - import tornado.httpserver - import tornado.ioloop - import tornado.wsgi - except ImportError: - raise Exception('tornado library not installed') - wsgi_container = tornado.wsgi.WSGIContainer(self.app) - http_server = tornado.httpserver.HTTPServer( - wsgi_container, **options) - http_server.listen(self.port, address=self.host) - logger.info('Listening on %s:%s..', self.host, self.port) - tornado.ioloop.IOLoop.instance().start() - elif self.server == 'gevent': - try: - import gevent.pywsgi - except ImportError: - raise Exception('gevent library not installed') - http_server = gevent.pywsgi.WSGIServer( - (self.host, self.port), self.app, **options) - logger.info('Listening on %s:%s..', self.host, self.port) - http_server.serve_forever() - else: - raise Exception(f'Server {self.server} not recognized') - - def __call__(self, scope, receive, send): # pragma: no cover - """ - ASGI interface. Calls the middleware wrapped around the wsgi app. - """ - return self.middleware(scope, receive, send) - - -class FlaskJSONEncoder(json.JSONEncoder): - def default(self, o): - if isinstance(o, datetime.datetime): - if o.tzinfo: - # eg: '2015-09-25T23:14:42.588601+00:00' - return o.isoformat('T') - else: - # No timezone present - assume UTC. - # eg: '2015-09-25T23:14:42.588601Z' - return o.isoformat('T') + 'Z' - - if isinstance(o, datetime.date): - return o.isoformat() - - if isinstance(o, Decimal): - return float(o) - - return json.JSONEncoder.default(self, o) - - -class NumberConverter(werkzeug.routing.BaseConverter): - """ Flask converter for OpenAPI number type """ - regex = r"[+-]?[0-9]*(\.[0-9]*)?" - - def to_python(self, value): - return float(value) - - -class IntegerConverter(werkzeug.routing.BaseConverter): - """ Flask converter for OpenAPI integer type """ - regex = r"[+-]?[0-9]+" - - def to_python(self, value): - return int(value) +""" +This module defines a FlaskApp, a Firetail application to wrap a Flask application. +""" + +import datetime +import logging +import pathlib +from decimal import Decimal +from types import FunctionType # NOQA + +import flask +import werkzeug.exceptions +from flask import json, signals + +from ..apis.flask_api import FlaskApi +from ..exceptions import ProblemException +from ..problem import problem +from .abstract import AbstractApp + +logger = logging.getLogger('firetail.app') + + +class FlaskApp(AbstractApp): + def __init__(self, import_name, server='flask', extra_files=None, **kwargs): + """ + :param extra_files: additional files to be watched by the reloader, defaults to the swagger specs of added apis + :type extra_files: list[str | pathlib.Path], optional + + See :class:`~firetail.AbstractApp` for additional parameters. + """ + super().__init__(import_name, FlaskApi, server=server, **kwargs) + self.extra_files = extra_files or [] + + def create_app(self): + app = flask.Flask(self.import_name, **self.server_args) + app.json_encoder = FlaskJSONEncoder + app.url_map.converters['float'] = NumberConverter + app.url_map.converters['int'] = IntegerConverter + return app + + def get_root_path(self): + return pathlib.Path(self.app.root_path) + + def set_errors_handlers(self): + for error_code in werkzeug.exceptions.default_exceptions: + self.add_error_handler(error_code, self.common_error_handler) + + self.add_error_handler(ProblemException, self.common_error_handler) + + def common_error_handler(self, exception): + """ + :type exception: Exception + """ + signals.got_request_exception.send(self.app, exception=exception) + if isinstance(exception, ProblemException): + response = problem( + status=exception.status, title=exception.title, detail=exception.detail, + type=exception.type, instance=exception.instance, headers=exception.headers, + ext=exception.ext) + else: + if not isinstance(exception, werkzeug.exceptions.HTTPException): + exception = werkzeug.exceptions.InternalServerError() + + response = problem(title=exception.name, + detail=exception.description, + status=exception.code, + headers=exception.get_headers()) + + return FlaskApi.get_response(response) + + def add_api(self, specification, **kwargs): + api = super().add_api(specification, **kwargs) + self.app.register_blueprint(api.blueprint) + if isinstance(specification, (str, pathlib.Path)): + self.extra_files.append(self.specification_dir / specification) + return api + + def add_error_handler(self, error_code, function): + # type: (int, FunctionType) -> None + self.app.register_error_handler(error_code, function) + + def run(self, + port=None, + server=None, + debug=None, + host=None, + extra_files=None, + **options): # pragma: no cover + """ + Runs the application on a local development server. + + :param host: the host interface to bind on. + :type host: str + :param port: port to listen to + :type port: int + :param server: which wsgi server to use + :type server: str | None + :param debug: include debugging information + :type debug: bool + :param extra_files: additional files to be watched by the reloader. + :type extra_files: Iterable[str | pathlib.Path] + :param options: options to be forwarded to the underlying server + """ + # this functions is not covered in unit tests because we would effectively testing the mocks + + # overwrite constructor parameter + if port is not None: + self.port = port + elif self.port is None: + self.port = 5000 + + self.host = host or self.host or '0.0.0.0' + + if server is not None: + self.server = server + + if debug is not None: + self.debug = debug + + if extra_files is not None: + self.extra_files.extend(extra_files) + + logger.debug('Starting %s HTTP server..', self.server, extra=vars(self)) + if self.server == 'flask': + self.app.run(self.host, port=self.port, debug=self.debug, + extra_files=self.extra_files, **options) + elif self.server == 'tornado': + try: + import tornado.httpserver + import tornado.ioloop + import tornado.wsgi + except ImportError: + raise Exception('tornado library not installed') + wsgi_container = tornado.wsgi.WSGIContainer(self.app) + http_server = tornado.httpserver.HTTPServer(wsgi_container, **options) + http_server.listen(self.port, address=self.host) + logger.info('Listening on %s:%s..', self.host, self.port) + tornado.ioloop.IOLoop.instance().start() + elif self.server == 'gevent': + try: + import gevent.pywsgi + except ImportError: + raise Exception('gevent library not installed') + http_server = gevent.pywsgi.WSGIServer((self.host, self.port), self.app, **options) + logger.info('Listening on %s:%s..', self.host, self.port) + http_server.serve_forever() + else: + raise Exception(f'Server {self.server} not recognized') + + +class FlaskJSONEncoder(json.JSONEncoder): + def default(self, o): + if isinstance(o, datetime.datetime): + if o.tzinfo: + # eg: '2015-09-25T23:14:42.588601+00:00' + return o.isoformat('T') + else: + # No timezone present - assume UTC. + # eg: '2015-09-25T23:14:42.588601Z' + return o.isoformat('T') + 'Z' + + if isinstance(o, datetime.date): + return o.isoformat() + + if isinstance(o, Decimal): + return float(o) + + return json.JSONEncoder.default(self, o) + + +class NumberConverter(werkzeug.routing.BaseConverter): + """ Flask converter for OpenAPI number type """ + regex = r"[+-]?[0-9]*(\.[0-9]*)?" + + def to_python(self, value): + return float(value) + + +class IntegerConverter(werkzeug.routing.BaseConverter): + """ Flask converter for OpenAPI integer type """ + regex = r"[+-]?[0-9]+" + + def to_python(self, value): + return int(value) diff --git a/firetail/auditor.py b/firetail/auditor.py index e0958a8..9ea0069 100644 --- a/firetail/auditor.py +++ b/firetail/auditor.py @@ -1,9 +1,7 @@ -import datetime import hashlib import json import logging import logging.config -import sys import time import jwt @@ -16,8 +14,7 @@ class cloud_logger(object): def __init__(self, app, - url='https://ingest.eu-west-1.dev.platform.pointsec.io/ingest/request', - api_key='5WqBxkOi3m6F1fDRryrR654xalAwz67815Rfe0ds', + url='https://api.logging.eu-west-1.sandbox.firetail.app/logs/bulk', debug=False, custom_backend=False, token=None, @@ -29,7 +26,6 @@ def __init__(self, scrub_headers=['set-cookie', 'cookie', 'authorization', 'x-api-key', 'token', 'api-token', 'api-key'], enrich_oauth=True ): - self.api_key = api_key self.startThread = True self.custom_backend = custom_backend self.requests_session = requests.Session() @@ -65,7 +61,6 @@ def __init__(self, 'custom_backend': self.custom_backend, 'logs_drain_timeout': 5, 'url': self.url, - 'api_key': self.api_key, 'retries_no': 4, 'retry_timeout': 2, } @@ -120,6 +115,12 @@ def clean_pii(self, payload): payload['oauth']['email'] = jwt_decoded['email'] return payload + def format_headers(self, req_headers): + result = {} + for x, y in req_headers.items(): + result[x] = [y] + return result + def create(self, response, token, diff=-1, scrub_headers=None, debug=False): if debug: self.stdout_logger = get_stdout_logger(True) @@ -131,39 +132,27 @@ def create(self, response, token, diff=-1, scrub_headers=None, debug=False): logging.config.dictConfig(self.LOGGING) self.logger = logging.getLogger('firetailLogger') try: - failed_res_body = False - response_data = response.get_json() if response.is_json else response.response[0].decode('utf-8') - except Exception as e: + response_data = response.get_json() if response.is_json else str(response.response[0].decode('utf-8')) + except Exception: response_data = "" - failed_res_body = True payload = { - "version": "1.1", - "dateCreated": int((datetime.datetime.utcnow()).timestamp() * 1000), - "execution_time": diff, - "source_code": sys.version, - "req": { + "version": "1.0.0-alpha", + "dateCreated": int(time.time() * 1000), + "executionTime": diff, + "request": { "httpProtocol": request.environ.get('SERVER_PROTOCOL', "HTTP/1.1"), - "url": request.base_url, - "headers": dict(request.headers), - "path": request.path, + "uri": request.url, + "headers": self.format_headers(dict(request.headers)), + "resource": request.url_rule.rule if request.url_rule is not None else request.path, "method": request.method, - "oPath": request.url_rule.rule if request.url_rule is not None else request.path, - "fPath": request.full_path, - "args": dict(request.args), "body": str(request.data), - "ip": request.remote_addr, - 'pathParams': request.view_args - + "ip": request.remote_addr }, - "resp": { - "status_code": response.status_code, - "content_len": response.content_length, - "content_enc": response.content_encoding, - "failed_res_body": failed_res_body, + "response": { + "statusCode": response.status_code, "body": response_data, - "headers": dict(response.headers), - "content_type": response.content_type - } + "headers": self.format_headers(dict(response.headers)) + }, } try: if self.token or self.custom_backend: diff --git a/firetail/cli.py b/firetail/cli.py index ccf31cf..e7d6914 100644 --- a/firetail/cli.py +++ b/firetail/cli.py @@ -1,206 +1,216 @@ -""" -This module defines a command-line interface (CLI) that runs an OpenAPI specification to be a -starting point for developing your API with Firetail. -""" - -import logging -import sys -from os import path - -import click -from clickclick import AliasedGroup, fatal_error - -import firetail -from firetail.mock import MockResolver - -logger = logging.getLogger('firetail.cli') -CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) -FLASK_APP = 'flask' -AVAILABLE_SERVERS = { - 'flask': [FLASK_APP], - 'gevent': [FLASK_APP], - 'tornado': [FLASK_APP], -} -AVAILABLE_APPS = { - FLASK_APP: 'firetail.apps.flask_app.FlaskApp', -} -DEFAULT_SERVERS = { - FLASK_APP: FLASK_APP, -} - - -def validate_server_requirements(ctx, param, value): - if value == 'gevent': - try: - import gevent # NOQA - except ImportError: - fatal_error('gevent library is not installed') - elif value == 'tornado': - try: - import tornado # NOQA - except ImportError: - fatal_error('tornado library is not installed') - else: - return value - - -def print_version(ctx, param, value): - if not value or ctx.resilient_parsing: - return - click.echo(f'Firetail {firetail.__version__}') - ctx.exit() - - -@click.group(cls=AliasedGroup, context_settings=CONTEXT_SETTINGS) -@click.option('-V', '--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True, - help='Print the current version number and exit.') -def main(): - pass - - -@main.command() -@click.argument('spec_file') -@click.argument('base_module_path', required=False) -@click.option('--port', '-p', default=5000, type=int, help='Port to listen.') -@click.option('--host', '-H', type=str, help='Host interface to bind on.') -@click.option('--wsgi-server', '-w', - type=click.Choice(AVAILABLE_SERVERS.keys()), - callback=validate_server_requirements, - help='Which WSGI server container to use. (deprecated, use --server instead)') -@click.option('--server', '-s', - type=click.Choice(AVAILABLE_SERVERS.keys()), - callback=validate_server_requirements, - help='Which server container to use.') -@click.option('--stub', - help='Returns status code 501, and `Not Implemented Yet` payload, for ' - 'the endpoints which handlers are not found.', - is_flag=True, default=False) -@click.option('--mock', type=click.Choice(['all', 'notimplemented']), - help='Returns example data for all endpoints or for which handlers are not found.') -@click.option('--hide-spec', - help='Hides the API spec in JSON format which is by default available at `/swagger.json`.', - is_flag=True, default=False) -@click.option('--hide-console-ui', - help='Hides the the API console UI which is by default available at `/ui`.', - is_flag=True, default=False) -@click.option('--console-ui-url', metavar='URL', - help='Personalize what URL path the API console UI will be mounted.') -@click.option('--console-ui-from', metavar='PATH', - help='Path to a customized API console UI dashboard.') -@click.option('--auth-all-paths', - help='Enable authentication to paths not defined in the spec.', - is_flag=True, default=False) -@click.option('--validate-responses', - help='Enable validation of response values from operation handlers.', - is_flag=True, default=False) -@click.option('--strict-validation', - help='Enable strict validation of request payloads.', - is_flag=True, default=False) -@click.option('--debug', '-d', help='Show debugging information.', - is_flag=True, default=False) -@click.option('--verbose', '-v', help='Show verbose information.', count=True) -@click.option('--base-path', metavar='PATH', - help='Override the basePath in the API spec.') -@click.option('--app-framework', '-f', default=FLASK_APP, - type=click.Choice(AVAILABLE_APPS.keys()), - help='The app framework used to run the server') -def run(spec_file, - base_module_path, - port, - host, - wsgi_server, - server, - stub, - mock, - hide_spec, - hide_console_ui, - console_ui_url, - console_ui_from, - auth_all_paths, - validate_responses, - strict_validation, - debug, - verbose, - base_path, - app_framework): - """ - Runs a server compliant with a OpenAPI/Swagger 2.0 Specification file. - - Arguments: - - - SPEC_FILE: specification file that describes the server endpoints. - - - BASE_MODULE_PATH (optional): filesystem path where the API endpoints handlers are going to be imported from. - """ - if wsgi_server and server: - raise click.BadParameter( - "these options are mutually exclusive", - param_hint="'wsgi-server' and 'server'" - ) - elif wsgi_server: - server = wsgi_server - - if server is None: - server = DEFAULT_SERVERS[app_framework] - - if app_framework not in AVAILABLE_SERVERS[server]: - message = "Invalid server '{}' for app-framework '{}'".format( - server, app_framework - ) - raise click.UsageError(message) - - logging_level = logging.WARN - if verbose > 0: - logging_level = logging.INFO - - if debug or verbose > 1: - logging_level = logging.DEBUG - debug = True - - logging.basicConfig(level=logging_level) - - spec_file_full_path = path.abspath(spec_file) - py_module_path = base_module_path or path.dirname(spec_file_full_path) - sys.path.insert(1, path.abspath(py_module_path)) - logger.debug(f'Added {py_module_path} to system path.') - - resolver_error = None - if stub: - resolver_error = 501 - - api_extra_args = {} - if mock: - resolver = MockResolver(mock_all=mock == 'all') - api_extra_args['resolver'] = resolver - - app_cls = firetail.utils.get_function_from_name( - AVAILABLE_APPS[app_framework] - ) - - options = { - "serve_spec": not hide_spec, - "swagger_path": console_ui_from or None, - "swagger_ui": not hide_console_ui, - "swagger_url": console_ui_url or None - } - - app = app_cls(__name__, - debug=debug, - auth_all_paths=auth_all_paths, - options=options) - - app.add_api(spec_file_full_path, - base_path=base_path, - resolver_error=resolver_error, - validate_responses=validate_responses, - strict_validation=strict_validation, - **api_extra_args) - - app.run(port=port, - host=host, - server=server, - debug=debug) - - -if __name__ == '__main__': # pragma: no cover - main() +""" +This module defines a command-line interface (CLI) that runs an OpenAPI specification to be a +starting point for developing your API with Firetail. +""" + +import logging +import sys +from os import path + +import click +from clickclick import AliasedGroup, fatal_error + +import firetail +from firetail.mock import MockResolver + +logger = logging.getLogger('firetail.cli') +CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) +FLASK_APP = 'flask' +AIOHTTP_APP = 'aiohttp' +AVAILABLE_SERVERS = { + 'flask': [FLASK_APP], + 'gevent': [FLASK_APP], + 'tornado': [FLASK_APP], + 'aiohttp': [AIOHTTP_APP] +} +AVAILABLE_APPS = { + FLASK_APP: 'firetail.apps.flask_app.FlaskApp', + AIOHTTP_APP: 'firetail.apps.aiohttp_app.AioHttpApp' +} +DEFAULT_SERVERS = { + FLASK_APP: FLASK_APP, + AIOHTTP_APP: AIOHTTP_APP +} + + +def validate_server_requirements(ctx, param, value): + if value == 'gevent': + try: + import gevent # NOQA + except ImportError: + fatal_error('gevent library is not installed') + elif value == 'tornado': + try: + import tornado # NOQA + except ImportError: + fatal_error('tornado library is not installed') + else: + return value + + +def print_version(ctx, param, value): + if not value or ctx.resilient_parsing: + return + click.echo(f'Firetail {firetail.__version__}') + ctx.exit() + + +@click.group(cls=AliasedGroup, context_settings=CONTEXT_SETTINGS) +@click.option('-V', '--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True, + help='Print the current version number and exit.') +def main(): + pass + + +@main.command() +@click.argument('spec_file') +@click.argument('base_module_path', required=False) +@click.option('--port', '-p', default=5000, type=int, help='Port to listen.') +@click.option('--host', '-H', type=str, help='Host interface to bind on.') +@click.option('--wsgi-server', '-w', + type=click.Choice(AVAILABLE_SERVERS.keys()), + callback=validate_server_requirements, + help='Which WSGI server container to use. (deprecated, use --server instead)') +@click.option('--server', '-s', + type=click.Choice(AVAILABLE_SERVERS.keys()), + callback=validate_server_requirements, + help='Which server container to use.') +@click.option('--stub', + help='Returns status code 501, and `Not Implemented Yet` payload, for ' + 'the endpoints which handlers are not found.', + is_flag=True, default=False) +@click.option('--mock', type=click.Choice(['all', 'notimplemented']), + help='Returns example data for all endpoints or for which handlers are not found.') +@click.option('--hide-spec', + help='Hides the API spec in JSON format which is by default available at `/swagger.json`.', + is_flag=True, default=False) +@click.option('--hide-console-ui', + help='Hides the the API console UI which is by default available at `/ui`.', + is_flag=True, default=False) +@click.option('--console-ui-url', metavar='URL', + help='Personalize what URL path the API console UI will be mounted.') +@click.option('--console-ui-from', metavar='PATH', + help='Path to a customized API console UI dashboard.') +@click.option('--auth-all-paths', + help='Enable authentication to paths not defined in the spec.', + is_flag=True, default=False) +@click.option('--validate-responses', + help='Enable validation of response values from operation handlers.', + is_flag=True, default=False) +@click.option('--strict-validation', + help='Enable strict validation of request payloads.', + is_flag=True, default=False) +@click.option('--debug', '-d', help='Show debugging information.', + is_flag=True, default=False) +@click.option('--verbose', '-v', help='Show verbose information.', count=True) +@click.option('--base-path', metavar='PATH', + help='Override the basePath in the API spec.') +@click.option('--app-framework', '-f', default=FLASK_APP, + type=click.Choice(AVAILABLE_APPS.keys()), + help='The app framework used to run the server') +def run(spec_file, + base_module_path, + port, + host, + wsgi_server, + server, + stub, + mock, + hide_spec, + hide_console_ui, + console_ui_url, + console_ui_from, + auth_all_paths, + validate_responses, + strict_validation, + debug, + verbose, + base_path, + app_framework): + """ + Runs a server compliant with a OpenAPI/Swagger 2.0 Specification file. + + Arguments: + + - SPEC_FILE: specification file that describes the server endpoints. + + - BASE_MODULE_PATH (optional): filesystem path where the API endpoints handlers are going to be imported from. + """ + if wsgi_server and server: + raise click.BadParameter( + "these options are mutually exclusive", + param_hint="'wsgi-server' and 'server'" + ) + elif wsgi_server: + server = wsgi_server + + if server is None: + server = DEFAULT_SERVERS[app_framework] + + if app_framework not in AVAILABLE_SERVERS[server]: + message = "Invalid server '{}' for app-framework '{}'".format( + server, app_framework + ) + raise click.UsageError(message) + + if app_framework == AIOHTTP_APP: + try: + import aiohttp # NOQA + except Exception: + fatal_error('aiohttp library is not installed') + + logging_level = logging.WARN + if verbose > 0: + logging_level = logging.INFO + + if debug or verbose > 1: + logging_level = logging.DEBUG + debug = True + + logging.basicConfig(level=logging_level) + + spec_file_full_path = path.abspath(spec_file) + py_module_path = base_module_path or path.dirname(spec_file_full_path) + sys.path.insert(1, path.abspath(py_module_path)) + logger.debug(f'Added {py_module_path} to system path.') + + resolver_error = None + if stub: + resolver_error = 501 + + api_extra_args = {} + if mock: + resolver = MockResolver(mock_all=mock == 'all') + api_extra_args['resolver'] = resolver + + app_cls = firetail.utils.get_function_from_name( + AVAILABLE_APPS[app_framework] + ) + + options = { + "serve_spec": not hide_spec, + "swagger_path": console_ui_from or None, + "swagger_ui": not hide_console_ui, + "swagger_url": console_ui_url or None + } + + app = app_cls(__name__, + debug=debug, + auth_all_paths=auth_all_paths, + options=options) + + app.add_api(spec_file_full_path, + base_path=base_path, + resolver_error=resolver_error, + validate_responses=validate_responses, + strict_validation=strict_validation, + **api_extra_args) + + app.run(port=port, + host=host, + server=server, + debug=debug) + + +if __name__ == '__main__': # pragma: no cover + main() diff --git a/firetail/decorators/__init__.py b/firetail/decorators/__init__.py index f8cdf53..0ba4fac 100644 --- a/firetail/decorators/__init__.py +++ b/firetail/decorators/__init__.py @@ -1,2 +1,3 @@ -""" -""" +""" +This module defines decorators which Firetail uses to wrap user provided view functions. +""" diff --git a/firetail/decorators/decorator.py b/firetail/decorators/decorator.py index 3a0df82..681851a 100644 --- a/firetail/decorators/decorator.py +++ b/firetail/decorators/decorator.py @@ -1,71 +1,71 @@ -""" -This module defines a BaseDecorator to wrap a user view function and a RequestResponseDecorator -which manages the lifecycle of a request internally in Firetail. -""" - -import asyncio -import functools -import logging - -from ..utils import has_coroutine - -logger = logging.getLogger('firetail.decorators.decorator') - - -class BaseDecorator: - - def __call__(self, function): - """ - :type function: types.FunctionType - :rtype: types.FunctionType - """ - return function - - def __repr__(self): # pragma: no cover - """ - :rtype: str - """ - return '' - - -class RequestResponseDecorator(BaseDecorator): - """Manages the lifecycle of the request internally in Firetail. - Filter the FiretailRequest instance to return the corresponding - framework specific object. - """ - - def __init__(self, api, mimetype): - self.api = api - self.mimetype = mimetype - - def __call__(self, function): - """ - :type function: types.FunctionType - :rtype: types.FunctionType - """ - if has_coroutine(function, self.api): - @functools.wraps(function) - async def wrapper(*args, **kwargs): - firetail_request = self.api.get_request(*args, **kwargs) - while asyncio.iscoroutine(firetail_request): - firetail_request = await firetail_request - - firetail_response = function(firetail_request) - while asyncio.iscoroutine(firetail_response): - firetail_response = await firetail_response - - framework_response = self.api.get_response(firetail_response, self.mimetype, - firetail_request) - while asyncio.iscoroutine(framework_response): - framework_response = await framework_response - - return framework_response - - else: # pragma: no cover - @functools.wraps(function) - def wrapper(*args, **kwargs): - request = self.api.get_request(*args, **kwargs) - response = function(request) - return self.api.get_response(response, self.mimetype, request) - - return wrapper +""" +This module defines a BaseDecorator to wrap a user view function and a RequestResponseDecorator +which manages the lifecycle of a request internally in Firetail. +""" + +import asyncio +import functools +import logging + +from ..utils import has_coroutine + +logger = logging.getLogger('firetail.decorators.decorator') + + +class BaseDecorator: + + def __call__(self, function): + """ + :type function: types.FunctionType + :rtype: types.FunctionType + """ + return function + + def __repr__(self): # pragma: no cover + """ + :rtype: str + """ + return '' + + +class RequestResponseDecorator(BaseDecorator): + """Manages the lifecycle of the request internally in Firetail. + Filter the FiretailRequest instance to return the corresponding + framework specific object. + """ + + def __init__(self, api, mimetype): + self.api = api + self.mimetype = mimetype + + def __call__(self, function): + """ + :type function: types.FunctionType + :rtype: types.FunctionType + """ + if has_coroutine(function, self.api): + @functools.wraps(function) + async def wrapper(*args, **kwargs): + firetail_request = self.api.get_request(*args, **kwargs) + while asyncio.iscoroutine(firetail_request): + firetail_request = await firetail_request + + firetail_response = function(firetail_request) + while asyncio.iscoroutine(firetail_response): + firetail_response = await firetail_response + + framework_response = self.api.get_response(firetail_response, self.mimetype, + firetail_request) + while asyncio.iscoroutine(framework_response): + framework_response = await framework_response + + return framework_response + + else: # pragma: no cover + @functools.wraps(function) + def wrapper(*args, **kwargs): + request = self.api.get_request(*args, **kwargs) + response = function(request) + return self.api.get_response(response, self.mimetype, request) + + return wrapper diff --git a/firetail/decorators/metrics.py b/firetail/decorators/metrics.py index dbda31e..3b4c776 100644 --- a/firetail/decorators/metrics.py +++ b/firetail/decorators/metrics.py @@ -1,61 +1,61 @@ -""" -This module defines view function decorator to collect UWSGI metrics and expose them via an -endpoint. -""" - -import functools -import os -import time - -from werkzeug.exceptions import HTTPException - -from firetail.exceptions import ProblemException - -try: - import uwsgi_metrics - HAS_UWSGI_METRICS = True # pragma: no cover -except ImportError: - uwsgi_metrics = None - HAS_UWSGI_METRICS = False - - -class UWSGIMetricsCollector: - def __init__(self, path, method): - self.path = path - self.method = method - swagger_path = path.strip('/').replace('/', '.').replace('<', '{').replace('>', '}') - self.key_suffix = f'{method.upper()}.{swagger_path}' - self.prefix = os.getenv('HTTP_METRICS_PREFIX', 'firetail.response') - - @staticmethod - def is_available(): - return HAS_UWSGI_METRICS - - def __call__(self, function): - """ - :type function: types.FunctionType - :rtype: types.FunctionType - """ - - @functools.wraps(function) - def wrapper(*args, **kwargs): - status = 500 - start_time_s = time.time() - try: - response = function(*args, **kwargs) - status = response.status_code - except HTTPException as http_e: - status = http_e.code - raise http_e - except ProblemException as prob_e: - status = prob_e.status - raise prob_e - finally: - end_time_s = time.time() - delta_s = end_time_s - start_time_s - delta_ms = delta_s * 1000 - key = f'{status}.{self.key_suffix}' - uwsgi_metrics.timer(self.prefix, key, delta_ms) - return response - - return wrapper +""" +This module defines view function decorator to collect UWSGI metrics and expose them via an +endpoint. +""" + +import functools +import os +import time + +from werkzeug.exceptions import HTTPException + +from firetail.exceptions import ProblemException + +try: + import uwsgi_metrics + HAS_UWSGI_METRICS = True # pragma: no cover +except ImportError: + uwsgi_metrics = None + HAS_UWSGI_METRICS = False + + +class UWSGIMetricsCollector: + def __init__(self, path, method): + self.path = path + self.method = method + swagger_path = path.strip('/').replace('/', '.').replace('<', '{').replace('>', '}') + self.key_suffix = f'{method.upper()}.{swagger_path}' + self.prefix = os.getenv('HTTP_METRICS_PREFIX', 'firetail.response') + + @staticmethod + def is_available(): + return HAS_UWSGI_METRICS + + def __call__(self, function): + """ + :type function: types.FunctionType + :rtype: types.FunctionType + """ + + @functools.wraps(function) + def wrapper(*args, **kwargs): + status = 500 + start_time_s = time.time() + try: + response = function(*args, **kwargs) + status = response.status_code + except HTTPException as http_e: + status = http_e.code + raise http_e + except ProblemException as prob_e: + status = prob_e.status + raise prob_e + finally: + end_time_s = time.time() + delta_s = end_time_s - start_time_s + delta_ms = delta_s * 1000 + key = f'{status}.{self.key_suffix}' + uwsgi_metrics.timer(self.prefix, key, delta_ms) + return response + + return wrapper diff --git a/firetail/decorators/parameter.py b/firetail/decorators/parameter.py index 6908947..5198ebf 100644 --- a/firetail/decorators/parameter.py +++ b/firetail/decorators/parameter.py @@ -1,119 +1,122 @@ -""" -This module defines a decorator to convert request parameters to arguments for the view function. -""" - -import builtins -import functools -import inspect -import keyword -import logging -import re -from typing import Any - -import inflection - -from ..http_facts import FORM_CONTENT_TYPES -from ..lifecycle import FiretailRequest # NOQA -from ..utils import all_json - -logger = logging.getLogger(__name__) - - -def inspect_function_arguments(function): # pragma: no cover - """ - Returns the list of variables names of a function and if it - accepts keyword arguments. - - :type function: Callable - :rtype: tuple[list[str], bool] - """ - parameters = inspect.signature(function).parameters - bound_arguments = [name for name, p in parameters.items() - if p.kind not in (p.VAR_POSITIONAL, p.VAR_KEYWORD)] - has_kwargs = any(p.kind == p.VAR_KEYWORD for p in parameters.values()) - return list(bound_arguments), has_kwargs - - -def snake_and_shadow(name): - """ - Converts the given name into Pythonic form. Firstly it converts CamelCase names to snake_case. Secondly it looks to - see if the name matches a known built-in and if it does it appends an underscore to the name. - :param name: The parameter name - :type name: str - :return: - """ - snake = inflection.underscore(name) - if snake in builtins.__dict__ or keyword.iskeyword(snake): - return f"{snake}_" - return snake - - -def parameter_to_arg(operation, function, pythonic_params=False, - pass_context_arg_name=None): - """ - Pass query and body parameters as keyword arguments to handler function. - - See (https://github.com/zalando/firetail/issues/59) - :param operation: The operation being called - :type operation: firetail.operations.AbstractOperation - :param pythonic_params: When True CamelCase parameters are converted to snake_case and an underscore is appended to - any shadowed built-ins - :type pythonic_params: bool - :param pass_context_arg_name: If not None URL and function has an argument matching this name, the framework's - request context will be passed as that argument. - :type pass_context_arg_name: str|None - """ - consumes = operation.consumes - - def sanitized(name): - return name and re.sub('^[^a-zA-Z_]+', '', re.sub('[^0-9a-zA-Z[_]', '', re.sub(r'[\[]', '_', name))) - - def pythonic(name): - name = name and snake_and_shadow(name) - return sanitized(name) - - sanitize = pythonic if pythonic_params else sanitized - arguments, has_kwargs = inspect_function_arguments(function) - - @functools.wraps(function) - def wrapper(request): - # type: (FiretailRequest) -> Any - logger.debug('Function Arguments: %s', arguments) - kwargs = {} - - if all_json(consumes): - request_body = request.json - elif consumes[0] in FORM_CONTENT_TYPES: - request_body = request.form - else: - request_body = request.body - - try: - query = request.query.to_dict(flat=False) - except AttributeError: - query = dict(request.query.items()) - - kwargs.update( - operation.get_arguments(request.path_params, query, request_body, - request.files, arguments, has_kwargs, sanitize) - ) - - # optionally convert parameter variable names to un-shadowed, snake_case form - if pythonic_params: - kwargs = {snake_and_shadow(k): v for k, v in kwargs.items()} - - # add context info (e.g. from security decorator) - for key, value in request.context.items(): - if has_kwargs or key in arguments: - kwargs[key] = value - else: - logger.debug( - "Context parameter '%s' not in function arguments", key) - - # attempt to provide the request context to the function - if pass_context_arg_name and (has_kwargs or pass_context_arg_name in arguments): - kwargs[pass_context_arg_name] = request.context - - return function(**kwargs) - - return wrapper +""" +This module defines a decorator to convert request parameters to arguments for the view function. +""" + +import builtins +import functools +import inspect +import keyword +import logging +import re +from typing import Any + +import inflection + +from ..http_facts import FORM_CONTENT_TYPES +from ..lifecycle import FiretailRequest # NOQA +from ..utils import all_json + +logger = logging.getLogger(__name__) + + +def inspect_function_arguments(function): # pragma: no cover + """ + Returns the list of variables names of a function and if it + accepts keyword arguments. + + :type function: Callable + :rtype: tuple[list[str], bool] + """ + parameters = inspect.signature(function).parameters + bound_arguments = [name for name, p in parameters.items() + if p.kind not in (p.VAR_POSITIONAL, p.VAR_KEYWORD)] + has_kwargs = any(p.kind == p.VAR_KEYWORD for p in parameters.values()) + return list(bound_arguments), has_kwargs + + +def snake_and_shadow(name): + """ + Converts the given name into Pythonic form. Firstly it converts CamelCase names to snake_case. Secondly it looks to + see if the name matches a known built-in and if it does it appends an underscore to the name. + :param name: The parameter name + :type name: str + :return: + """ + snake = inflection.underscore(name) + if snake in builtins.__dict__ or keyword.iskeyword(snake): + return f"{snake}_" + return snake + + +def sanitized(name): + return name and re.sub('^[^a-zA-Z_]+', '', + re.sub('[^0-9a-zA-Z_]', '', + re.sub(r'\[(?!])', '_', name))) + + +def pythonic(name): + name = name and snake_and_shadow(name) + return sanitized(name) + + +def parameter_to_arg(operation, function, pythonic_params=False, + pass_context_arg_name=None): + """ + Pass query and body parameters as keyword arguments to handler function. + + See (https://github.com/zalando/firetail/issues/59) + :param operation: The operation being called + :type operation: firetail.operations.AbstractOperation + :param pythonic_params: When True CamelCase parameters are converted to snake_case and an underscore is appended to + any shadowed built-ins + :type pythonic_params: bool + :param pass_context_arg_name: If not None URL and function has an argument matching this name, the framework's + request context will be passed as that argument. + :type pass_context_arg_name: str|None + """ + consumes = operation.consumes + + sanitize = pythonic if pythonic_params else sanitized + arguments, has_kwargs = inspect_function_arguments(function) + + @functools.wraps(function) + def wrapper(request): + # type: (FiretailRequest) -> Any + logger.debug('Function Arguments: %s', arguments) + kwargs = {} + + if all_json(consumes): + request_body = request.json + elif consumes[0] in FORM_CONTENT_TYPES: + request_body = request.form + else: + request_body = request.body + + try: + query = request.query.to_dict(flat=False) + except AttributeError: + query = dict(request.query.items()) + + kwargs.update( + operation.get_arguments(request.path_params, query, request_body, + request.files, arguments, has_kwargs, sanitize) + ) + + # optionally convert parameter variable names to un-shadowed, snake_case form + if pythonic_params: + kwargs = {snake_and_shadow(k): v for k, v in kwargs.items()} + + # add context info (e.g. from security decorator) + for key, value in request.context.items(): + if has_kwargs or key in arguments: + kwargs[key] = value + else: + logger.debug("Context parameter '%s' not in function arguments", key) + + # attempt to provide the request context to the function + if pass_context_arg_name and (has_kwargs or pass_context_arg_name in arguments): + kwargs[pass_context_arg_name] = request.context + + return function(**kwargs) + + return wrapper diff --git a/firetail/decorators/produces.py b/firetail/decorators/produces.py index a2f996c..ba012a2 100644 --- a/firetail/decorators/produces.py +++ b/firetail/decorators/produces.py @@ -1,52 +1,52 @@ -""" -This module defines decorators to change the return type of a view function. -""" - -import functools -import logging - -from .decorator import BaseDecorator - -logger = logging.getLogger('firetail.decorators.produces') - -# special marker object to return empty content for any status code -# e.g. in app method do "return NoContent, 201" -NoContent = object() - - -class BaseSerializer(BaseDecorator): - def __init__(self, mimetype='text/plain'): - """ - :type mimetype: str - """ - self.mimetype = mimetype - - def __repr__(self): - """ - :rtype: str - """ - return f'' # pragma: no cover - - -class Produces(BaseSerializer): - def __call__(self, function): - """ - :type function: types.FunctionType - :rtype: types.FunctionType - """ - - @functools.wraps(function) - def wrapper(request): - url = request.url - response = function(request) - logger.debug('Returning %s', url, - extra={'url': url, 'mimetype': self.mimetype}) - return response - - return wrapper - - def __repr__(self): - """ - :rtype: str - """ - return f'' # pragma: no cover +""" +This module defines decorators to change the return type of a view function. +""" + +import functools +import logging + +from .decorator import BaseDecorator + +logger = logging.getLogger('firetail.decorators.produces') + +# special marker object to return empty content for any status code +# e.g. in app method do "return NoContent, 201" +NoContent = object() + + +class BaseSerializer(BaseDecorator): + def __init__(self, mimetype='text/plain'): + """ + :type mimetype: str + """ + self.mimetype = mimetype + + def __repr__(self): + """ + :rtype: str + """ + return f'' # pragma: no cover + + +class Produces(BaseSerializer): + def __call__(self, function): + """ + :type function: types.FunctionType + :rtype: types.FunctionType + """ + + @functools.wraps(function) + def wrapper(request): + url = request.url + response = function(request) + logger.debug('Returning %s', url, + extra={'url': url, 'mimetype': self.mimetype}) + return response + + return wrapper + + def __repr__(self): + """ + :rtype: str + """ + return f'' # pragma: no cover diff --git a/firetail/decorators/response.py b/firetail/decorators/response.py index edbdce6..120dfa9 100644 --- a/firetail/decorators/response.py +++ b/firetail/decorators/response.py @@ -1,121 +1,120 @@ -""" -This module defines a view function decorator to validate its responses. -""" - -import asyncio -import functools -import logging - -from jsonschema import ValidationError - -from ..exceptions import (NonConformingResponseBody, - NonConformingResponseHeaders) -from ..utils import all_json, has_coroutine -from .decorator import BaseDecorator -from .validation import ResponseBodyValidator - -logger = logging.getLogger('firetail.decorators.response') - - -class ResponseValidator(BaseDecorator): - def __init__(self, operation, mimetype, validator=None): - """ - :type operation: Operation - :type mimetype: str - :param validator: Validator class that should be used to validate passed data - against API schema. - :type validator: jsonschema.IValidator - """ - self.operation = operation - self.mimetype = mimetype - self.validator = validator - - def validate_response(self, data, status_code, headers, url): - """ - Validates the Response object based on what has been declared in the specification. - Ensures the response body matches the declared schema. - :type data: dict - :type status_code: int - :type headers: dict - :rtype bool | None - """ - # check against returned header, fall back to expected mimetype - content_type = headers.get("Content-Type", self.mimetype) - content_type = content_type.rsplit(";", 1)[0] # remove things like utf8 metadata - - response_definition = self.operation.response_definition(str(status_code), content_type) - response_schema = self.operation.response_schema(str(status_code), content_type) - - if self.is_json_schema_compatible(response_schema): - v = ResponseBodyValidator(response_schema, validator=self.validator) - try: - data = self.operation.json_loads(data) - v.validate_schema(data, url) - except ValidationError as e: - raise NonConformingResponseBody(message=str(e)) - - if response_definition and response_definition.get("headers"): - required_header_keys = {k for (k, v) in response_definition.get("headers").items() - if v.get("required", False)} - header_keys = set(headers.keys()) - missing_keys = required_header_keys - header_keys - if missing_keys: - pretty_list = ', '.join(missing_keys) - msg = ("Keys in header don't match response specification. " - "Difference: {}").format(pretty_list) - raise NonConformingResponseHeaders(message=msg) - return True - - def is_json_schema_compatible(self, response_schema: dict) -> bool: - """ - Verify if the specified operation responses are JSON schema - compatible. - - All operations that specify a JSON schema and have content - type "application/json" or "text/plain" can be validated using - json_schema package. - """ - if not response_schema: - return False - return all_json([self.mimetype]) or self.mimetype == 'text/plain' - - def __call__(self, function): - """ - :type function: types.FunctionType - :rtype: types.FunctionType - """ - - def _wrapper(request, response): - firetail_response = \ - self.operation.api.get_firetail_response(response, self.mimetype) - if not firetail_response.is_streamed: - self.validate_response( - firetail_response.body, firetail_response.status_code, - firetail_response.headers, request.url) - else: - logger.warning("Skipping response validation for streamed response.") - - return response - - if has_coroutine(function): - @functools.wraps(function) - async def wrapper(request): - response = function(request) - while asyncio.iscoroutine(response): - response = await response - - return _wrapper(request, response) - - else: # pragma: no cover - @functools.wraps(function) - def wrapper(request): - response = function(request) - return _wrapper(request, response) - - return wrapper - - def __repr__(self): - """ - :rtype: str - """ - return '' # pragma: no cover +""" +This module defines a view function decorator to validate its responses. +""" + +import asyncio +import functools +import logging + +from jsonschema import ValidationError + +from ..exceptions import NonConformingResponseBody, NonConformingResponseHeaders +from ..utils import all_json, has_coroutine +from .decorator import BaseDecorator +from .validation import ResponseBodyValidator + +logger = logging.getLogger('firetail.decorators.response') + + +class ResponseValidator(BaseDecorator): + def __init__(self, operation, mimetype, validator=None): + """ + :type operation: Operation + :type mimetype: str + :param validator: Validator class that should be used to validate passed data + against API schema. + :type validator: jsonschema.IValidator + """ + self.operation = operation + self.mimetype = mimetype + self.validator = validator + + def validate_response(self, data, status_code, headers, url): + """ + Validates the Response object based on what has been declared in the specification. + Ensures the response body matches the declared schema. + :type data: dict + :type status_code: int + :type headers: dict + :rtype bool | None + """ + # check against returned header, fall back to expected mimetype + content_type = headers.get("Content-Type", self.mimetype) + content_type = content_type.rsplit(";", 1)[0] # remove things like utf8 metadata + + response_definition = self.operation.response_definition(str(status_code), content_type) + response_schema = self.operation.response_schema(str(status_code), content_type) + + if self.is_json_schema_compatible(response_schema): + v = ResponseBodyValidator(response_schema, validator=self.validator) + try: + data = self.operation.json_loads(data) + v.validate_schema(data, url) + except ValidationError as e: + raise NonConformingResponseBody(message=str(e)) + + if response_definition and response_definition.get("headers"): + required_header_keys = {k for (k, v) in response_definition.get("headers").items() + if v.get("required", False)} + header_keys = set(headers.keys()) + missing_keys = required_header_keys - header_keys + if missing_keys: + pretty_list = ', '.join(missing_keys) + msg = ("Keys in header don't match response specification. " + "Difference: {}").format(pretty_list) + raise NonConformingResponseHeaders(message=msg) + return True + + def is_json_schema_compatible(self, response_schema: dict) -> bool: + """ + Verify if the specified operation responses are JSON schema + compatible. + + All operations that specify a JSON schema and have content + type "application/json" or "text/plain" can be validated using + json_schema package. + """ + if not response_schema: + return False + return all_json([self.mimetype]) or self.mimetype == 'text/plain' + + def __call__(self, function): + """ + :type function: types.FunctionType + :rtype: types.FunctionType + """ + + def _wrapper(request, response): + firetail_response = \ + self.operation.api.get_firetail_response(response, self.mimetype) + if not firetail_response.is_streamed: + self.validate_response( + firetail_response.body, firetail_response.status_code, + firetail_response.headers, request.url) + else: + logger.warning("Skipping response validation for streamed response.") + + return response + + if has_coroutine(function): + @functools.wraps(function) + async def wrapper(request): + response = function(request) + while asyncio.iscoroutine(response): + response = await response + + return _wrapper(request, response) + + else: # pragma: no cover + @functools.wraps(function) + def wrapper(request): + response = function(request) + return _wrapper(request, response) + + return wrapper + + def __repr__(self): + """ + :rtype: str + """ + return '' # pragma: no cover diff --git a/firetail/decorators/uri_parsing.py b/firetail/decorators/uri_parsing.py index f47d225..38e22cc 100644 --- a/firetail/decorators/uri_parsing.py +++ b/firetail/decorators/uri_parsing.py @@ -1,344 +1,351 @@ -""" -This module defines view function decorators to split query and path parameters. -""" - -import abc -import functools -import json -import logging -import re - -from .. import utils -from .decorator import BaseDecorator - -logger = logging.getLogger('firetail.decorators.uri_parsing') - -QUERY_STRING_DELIMITERS = { - 'spaceDelimited': ' ', - 'pipeDelimited': '|', - 'simple': ',', - 'form': ',' -} - - -class AbstractURIParser(BaseDecorator, metaclass=abc.ABCMeta): - parsable_parameters = ["query", "path"] - - def __init__(self, param_defns, body_defn): - """ - a URI parser is initialized with parameter definitions. - When called with a request object, it handles array types in the URI - both in the path and query according to the spec. - Some examples include: - - https://mysite.fake/in/path/1,2,3/ # path parameters - - https://mysite.fake/?in_query=a,b,c # simple query params - - https://mysite.fake/?in_query=a|b|c # various separators - - https://mysite.fake/?in_query=a&in_query=b,c # complex query params - """ - self._param_defns = {p["name"]: p - for p in param_defns - if p["in"] in self.parsable_parameters} - self._body_schema = body_defn.get("schema", {}) - self._body_encoding = body_defn.get("encoding", {}) - - @property - @abc.abstractmethod - def param_defns(self): - """ - returns the parameter definitions by name - """ - - @property - @abc.abstractmethod - def param_schemas(self): - """ - returns the parameter schemas by name - """ - - def __repr__(self): - """ - :rtype: str - """ - return "<{classname}>".format( - classname=self.__class__.__name__) # pragma: no cover - - @abc.abstractmethod - def resolve_form(self, form_data): - """ Resolve cases where form parameters are provided multiple times. - """ - - @abc.abstractmethod - def resolve_query(self, query_data): - """ Resolve cases where query parameters are provided multiple times. - """ - - @abc.abstractmethod - def resolve_path(self, path): - """ Resolve cases where path parameters include lists - """ - - @abc.abstractmethod - def _resolve_param_duplicates(self, values, param_defn, _in): - """ Resolve cases where query parameters are provided multiple times. - For example, if the query string is '?a=1,2,3&a=4,5,6' the value of - `a` could be "4,5,6", or "1,2,3" or "1,2,3,4,5,6" depending on the - implementation. - """ - - @abc.abstractmethod - def _split(self, value, param_defn, _in): - """ - takes a string, a parameter definition, and a parameter type - and returns an array that has been constructed according to - the parameter definition. - """ - - def resolve_params(self, params, _in): - """ - takes a dict of parameters, and resolves the values into - the correct array type handling duplicate values, and splitting - based on the collectionFormat defined in the spec. - """ - resolved_param = {} - for k, values in params.items(): - param_defn = self.param_defns.get(k) - param_schema = self.param_schemas.get(k) - - if not (param_defn or param_schema): - # rely on validation - resolved_param[k] = values - continue - - if _in == 'path': - # multiple values in a path is impossible - values = [values] - - if param_schema and param_schema['type'] == 'array': - # resolve variable re-assignment, handle explode - values = self._resolve_param_duplicates(values, param_defn, _in) - # handle array styles - resolved_param[k] = self._split(values, param_defn, _in) - else: - resolved_param[k] = values[-1] - - return resolved_param - - def __call__(self, function): - """ - :type function: types.FunctionType - :rtype: types.FunctionType - """ - - @functools.wraps(function) - def wrapper(request): - def coerce_dict(md): - """ MultiDict -> dict of lists - """ - try: - return md.to_dict(flat=False) - except AttributeError: - return dict(md.items()) - - query = coerce_dict(request.query) - path_params = coerce_dict(request.path_params) - form = coerce_dict(request.form) - - request.query = self.resolve_query(query) - request.path_params = self.resolve_path(path_params) - request.form = self.resolve_form(form) - response = function(request) - return response - - return wrapper - - -class OpenAPIURIParser(AbstractURIParser): - style_defaults = {"path": "simple", "header": "simple", - "query": "form", "cookie": "form", - "form": "form"} - - @property - def param_defns(self): - return self._param_defns - - @property - def form_defns(self): - return {k: v for k, v in self._body_schema.get('properties', {}).items()} - - @property - def param_schemas(self): - return {k: v.get('schema', {}) for k, v in self.param_defns.items()} - - def resolve_form(self, form_data): - if self._body_schema is None or self._body_schema.get('type') != 'object': - return form_data - for k in form_data: - encoding = self._body_encoding.get(k, {"style": "form"}) - defn = self.form_defns.get(k, {}) - # TODO support more form encoding styles - form_data[k] = \ - self._resolve_param_duplicates(form_data[k], encoding, 'form') - if defn and defn["type"] == "array": - form_data[k] = self._split(form_data[k], encoding, 'form') - elif 'contentType' in encoding and utils.all_json([encoding.get('contentType')]): - form_data[k] = json.loads(form_data[k]) - return form_data - - def _make_deep_object(self, k, v): - """ consumes keys, value pairs like (a[foo][bar], "baz") - returns (a, {"foo": {"bar": "baz"}}}, is_deep_object) - """ - root_key = None - if k in self.param_schemas.keys(): - return k, v, False - else: - for keys in self.param_schemas.keys(): - if k.startswith(keys): - rest = keys.replace(k, '') - root_key = rest - - if not root_key: - root_key = k.split("[", 1)[0] - if k == root_key: - return k, v, False - - key_path = re.findall(r'\[([^\[\]]*)\]', k) - root = prev = node = {} - for k in key_path: - node[k] = {} - prev = node - node = node[k] - prev[k] = v[0] - return root_key, [root], True - - def _preprocess_deep_objects(self, query_data): - """ deep objects provide a way of rendering nested objects using query - parameters. - """ - deep = [self._make_deep_object(k, v) for k, v in query_data.items()] - root_keys = [k for k, v, is_deep_object in deep] - ret = dict.fromkeys(root_keys, [{}]) - for k, v, is_deep_object in deep: - if is_deep_object: - ret[k] = [utils.deep_merge(v[0], ret[k][0])] - else: - ret[k] = v - return ret - - def resolve_query(self, query_data): - query_data = self._preprocess_deep_objects(query_data) - return self.resolve_params(query_data, 'query') - - def resolve_path(self, path_data): - return self.resolve_params(path_data, 'path') - - @staticmethod - def _resolve_param_duplicates(values, param_defn, _in): - """ Resolve cases where query parameters are provided multiple times. - The default behavior is to use the first-defined value. - For example, if the query string is '?a=1,2,3&a=4,5,6' the value of - `a` would be "4,5,6". - However, if 'explode' is 'True' then the duplicate values - are concatenated together and `a` would be "1,2,3,4,5,6". - """ - default_style = OpenAPIURIParser.style_defaults[_in] - style = param_defn.get('style', default_style) - delimiter = QUERY_STRING_DELIMITERS.get(style, ',') - is_form = (style == 'form') - explode = param_defn.get('explode', is_form) - if explode: - return delimiter.join(values) - - # default to last defined value - return values[-1] - - @staticmethod - def _split(value, param_defn, _in): - default_style = OpenAPIURIParser.style_defaults[_in] - style = param_defn.get('style', default_style) - delimiter = QUERY_STRING_DELIMITERS.get(style, ',') - return value.split(delimiter) - - -class Swagger2URIParser(AbstractURIParser): - """ - Adheres to the Swagger2 spec, - Assumes the the last defined query parameter should be used. - """ - parsable_parameters = ["query", "path", "formData"] - - @property - def param_defns(self): - return self._param_defns - - @property - def param_schemas(self): - return self._param_defns # swagger2 conflates defn and schema - - def resolve_form(self, form_data): - return self.resolve_params(form_data, 'form') - - def resolve_query(self, query_data): - return self.resolve_params(query_data, 'query') - - def resolve_path(self, path_data): - return self.resolve_params(path_data, 'path') - - @staticmethod - def _resolve_param_duplicates(values, param_defn, _in): - """ Resolve cases where query parameters are provided multiple times. - The default behavior is to use the first-defined value. - For example, if the query string is '?a=1,2,3&a=4,5,6' the value of - `a` would be "4,5,6". - However, if 'collectionFormat' is 'multi' then the duplicate values - are concatenated together and `a` would be "1,2,3,4,5,6". - """ - if param_defn.get('collectionFormat') == 'multi': - return ','.join(values) - # default to last defined value - return values[-1] - - @staticmethod - def _split(value, param_defn, _in): - if param_defn.get("collectionFormat") == 'pipes': - return value.split('|') - return value.split(',') - - -class FirstValueURIParser(Swagger2URIParser): - """ - Adheres to the Swagger2 spec - Assumes that the first defined query parameter should be used - """ - - @staticmethod - def _resolve_param_duplicates(values, param_defn, _in): - """ Resolve cases where query parameters are provided multiple times. - The default behavior is to use the first-defined value. - For example, if the query string is '?a=1,2,3&a=4,5,6' the value of - `a` would be "1,2,3". - However, if 'collectionFormat' is 'multi' then the duplicate values - are concatenated together and `a` would be "1,2,3,4,5,6". - """ - if param_defn.get('collectionFormat') == 'multi': - return ','.join(values) - # default to first defined value - return values[0] - - -class AlwaysMultiURIParser(Swagger2URIParser): - """ - Does not adhere to the Swagger2 spec, but is backwards compatible with - firetail behavior in version 1.4.2 - """ - - @staticmethod - def _resolve_param_duplicates(values, param_defn, _in): - """ Resolve cases where query parameters are provided multiple times. - The default behavior is to join all provided parameters together. - For example, if the query string is '?a=1,2,3&a=4,5,6' the value of - `a` would be "1,2,3,4,5,6". - """ - if param_defn.get('collectionFormat') == 'pipes': - return '|'.join(values) - return ','.join(values) +""" +This module defines view function decorators to split query and path parameters. +""" + +import abc +import functools +import json +import logging +import re + +from .. import utils +from .decorator import BaseDecorator + +logger = logging.getLogger('firetail.decorators.uri_parsing') + +QUERY_STRING_DELIMITERS = { + 'spaceDelimited': ' ', + 'pipeDelimited': '|', + 'simple': ',', + 'form': ',' +} + + +class AbstractURIParser(BaseDecorator, metaclass=abc.ABCMeta): + parsable_parameters = ["query", "path"] + + def __init__(self, param_defns, body_defn): + """ + a URI parser is initialized with parameter definitions. + When called with a request object, it handles array types in the URI + both in the path and query according to the spec. + Some examples include: + - https://mysite.fake/in/path/1,2,3/ # path parameters + - https://mysite.fake/?in_query=a,b,c # simple query params + - https://mysite.fake/?in_query=a|b|c # various separators + - https://mysite.fake/?in_query=a&in_query=b,c # complex query params + """ + self._param_defns = {p["name"]: p + for p in param_defns + if p["in"] in self.parsable_parameters} + self._body_schema = body_defn.get("schema", {}) + self._body_encoding = body_defn.get("encoding", {}) + + @property + @abc.abstractmethod + def param_defns(self): + """ + returns the parameter definitions by name + """ + + @property + @abc.abstractmethod + def param_schemas(self): + """ + returns the parameter schemas by name + """ + + def __repr__(self): + """ + :rtype: str + """ + return "<{classname}>".format( + classname=self.__class__.__name__) # pragma: no cover + + @abc.abstractmethod + def resolve_form(self, form_data): + """ Resolve cases where form parameters are provided multiple times. + """ + + @abc.abstractmethod + def resolve_query(self, query_data): + """ Resolve cases where query parameters are provided multiple times. + """ + + @abc.abstractmethod + def resolve_path(self, path): + """ Resolve cases where path parameters include lists + """ + + @abc.abstractmethod + def _resolve_param_duplicates(self, values, param_defn, _in): + """ Resolve cases where query parameters are provided multiple times. + For example, if the query string is '?a=1,2,3&a=4,5,6' the value of + `a` could be "4,5,6", or "1,2,3" or "1,2,3,4,5,6" depending on the + implementation. + """ + + @abc.abstractmethod + def _split(self, value, param_defn, _in): + """ + takes a string, a parameter definition, and a parameter type + and returns an array that has been constructed according to + the parameter definition. + """ + + def resolve_params(self, params, _in): + """ + takes a dict of parameters, and resolves the values into + the correct array type handling duplicate values, and splitting + based on the collectionFormat defined in the spec. + """ + resolved_param = {} + for k, values in params.items(): + param_defn = self.param_defns.get(k) + param_schema = self.param_schemas.get(k) + + if not (param_defn or param_schema): + # rely on validation + resolved_param[k] = values + continue + + if _in == 'path': + # multiple values in a path is impossible + values = [values] + + if param_schema and param_schema['type'] == 'array': + # resolve variable re-assignment, handle explode + values = self._resolve_param_duplicates(values, param_defn, _in) + # handle array styles + resolved_param[k] = self._split(values, param_defn, _in) + else: + resolved_param[k] = values[-1] + + return resolved_param + + def __call__(self, function): + """ + :type function: types.FunctionType + :rtype: types.FunctionType + """ + + @functools.wraps(function) + def wrapper(request): + def coerce_dict(md): + """ MultiDict -> dict of lists + """ + try: + return md.to_dict(flat=False) + except AttributeError: + return dict(md.items()) + + query = coerce_dict(request.query) + path_params = coerce_dict(request.path_params) + form = coerce_dict(request.form) + + request.query = self.resolve_query(query) + request.path_params = self.resolve_path(path_params) + request.form = self.resolve_form(form) + response = function(request) + return response + + return wrapper + + +class OpenAPIURIParser(AbstractURIParser): + style_defaults = {"path": "simple", "header": "simple", + "query": "form", "cookie": "form", + "form": "form"} + + @property + def param_defns(self): + return self._param_defns + + @property + def form_defns(self): + return {k: v for k, v in self._body_schema.get('properties', {}).items()} + + @property + def param_schemas(self): + return {k: v.get('schema', {}) for k, v in self.param_defns.items()} + + def resolve_form(self, form_data): + if self._body_schema is None or self._body_schema.get('type') != 'object': + return form_data + for k in form_data: + encoding = self._body_encoding.get(k, {"style": "form"}) + defn = self.form_defns.get(k, {}) + # TODO support more form encoding styles + form_data[k] = \ + self._resolve_param_duplicates(form_data[k], encoding, 'form') + if defn and defn["type"] == "array": + form_data[k] = self._split(form_data[k], encoding, 'form') + elif 'contentType' in encoding and utils.all_json([encoding.get('contentType')]): + form_data[k] = json.loads(form_data[k]) + return form_data + + def _make_deep_object(self, k, v): + """ consumes keys, value pairs like (a[foo][bar], "baz") + returns (a, {"foo": {"bar": "baz"}}}, is_deep_object) + """ + root_key = None + if k in self.param_schemas.keys(): + return k, v, False + else: + for key in self.param_schemas.keys(): + if k.startswith(key) and "[" in k: + root_key = key.replace(k, '') + + if not root_key: + root_key = k.split("[", 1)[0] + if k == root_key: + return k, v, False + + if not self._is_deep_object_style_param(root_key): + return k, v, False + + key_path = re.findall(r'\[([^\[\]]*)\]', k) + root = prev = node = {} + for k in key_path: + node[k] = {} + prev = node + node = node[k] + prev[k] = v[0] + return root_key, [root], True + + def _is_deep_object_style_param(self, param_name): + default_style = self.style_defaults["query"] + style = self.param_defns.get(param_name, {}).get('style', default_style) + return style == "deepObject" + + def _preprocess_deep_objects(self, query_data): + """ deep objects provide a way of rendering nested objects using query + parameters. + """ + deep = [self._make_deep_object(k, v) for k, v in query_data.items()] + root_keys = [k for k, v, is_deep_object in deep] + ret = dict.fromkeys(root_keys, [{}]) + for k, v, is_deep_object in deep: + if is_deep_object: + ret[k] = [utils.deep_merge(v[0], ret[k][0])] + else: + ret[k] = v + return ret + + def resolve_query(self, query_data): + query_data = self._preprocess_deep_objects(query_data) + return self.resolve_params(query_data, 'query') + + def resolve_path(self, path_data): + return self.resolve_params(path_data, 'path') + + @staticmethod + def _resolve_param_duplicates(values, param_defn, _in): + """ Resolve cases where query parameters are provided multiple times. + The default behavior is to use the first-defined value. + For example, if the query string is '?a=1,2,3&a=4,5,6' the value of + `a` would be "4,5,6". + However, if 'explode' is 'True' then the duplicate values + are concatenated together and `a` would be "1,2,3,4,5,6". + """ + default_style = OpenAPIURIParser.style_defaults[_in] + style = param_defn.get('style', default_style) + delimiter = QUERY_STRING_DELIMITERS.get(style, ',') + is_form = (style == 'form') + explode = param_defn.get('explode', is_form) + if explode: + return delimiter.join(values) + + # default to last defined value + return values[-1] + + @staticmethod + def _split(value, param_defn, _in): + default_style = OpenAPIURIParser.style_defaults[_in] + style = param_defn.get('style', default_style) + delimiter = QUERY_STRING_DELIMITERS.get(style, ',') + return value.split(delimiter) + + +class Swagger2URIParser(AbstractURIParser): + """ + Adheres to the Swagger2 spec, + Assumes the the last defined query parameter should be used. + """ + parsable_parameters = ["query", "path", "formData"] + + @property + def param_defns(self): + return self._param_defns + + @property + def param_schemas(self): + return self._param_defns # swagger2 conflates defn and schema + + def resolve_form(self, form_data): + return self.resolve_params(form_data, 'form') + + def resolve_query(self, query_data): + return self.resolve_params(query_data, 'query') + + def resolve_path(self, path_data): + return self.resolve_params(path_data, 'path') + + @staticmethod + def _resolve_param_duplicates(values, param_defn, _in): + """ Resolve cases where query parameters are provided multiple times. + The default behavior is to use the first-defined value. + For example, if the query string is '?a=1,2,3&a=4,5,6' the value of + `a` would be "4,5,6". + However, if 'collectionFormat' is 'multi' then the duplicate values + are concatenated together and `a` would be "1,2,3,4,5,6". + """ + if param_defn.get('collectionFormat') == 'multi': + return ','.join(values) + # default to last defined value + return values[-1] + + @staticmethod + def _split(value, param_defn, _in): + if param_defn.get("collectionFormat") == 'pipes': + return value.split('|') + return value.split(',') + + +class FirstValueURIParser(Swagger2URIParser): + """ + Adheres to the Swagger2 spec + Assumes that the first defined query parameter should be used + """ + + @staticmethod + def _resolve_param_duplicates(values, param_defn, _in): + """ Resolve cases where query parameters are provided multiple times. + The default behavior is to use the first-defined value. + For example, if the query string is '?a=1,2,3&a=4,5,6' the value of + `a` would be "1,2,3". + However, if 'collectionFormat' is 'multi' then the duplicate values + are concatenated together and `a` would be "1,2,3,4,5,6". + """ + if param_defn.get('collectionFormat') == 'multi': + return ','.join(values) + # default to first defined value + return values[0] + + +class AlwaysMultiURIParser(Swagger2URIParser): + """ + Does not adhere to the Swagger2 spec, but is backwards compatible with + firetail behavior in version 1.4.2 + """ + + @staticmethod + def _resolve_param_duplicates(values, param_defn, _in): + """ Resolve cases where query parameters are provided multiple times. + The default behavior is to join all provided parameters together. + For example, if the query string is '?a=1,2,3&a=4,5,6' the value of + `a` would be "1,2,3,4,5,6". + """ + if param_defn.get('collectionFormat') == 'pipes': + return '|'.join(values) + return ','.join(values) diff --git a/firetail/decorators/validation.py b/firetail/decorators/validation.py index 5205462..2f6bbb4 100644 --- a/firetail/decorators/validation.py +++ b/firetail/decorators/validation.py @@ -1,414 +1,404 @@ -""" -This module defines view function decorators to validate request and response parameters and bodies. -""" - -import collections -import copy -import functools -import logging -from typing import AnyStr, Union - -try: - from importlib.metadata import version -except ImportError: - from importlib_metadata import version - -from jsonschema import Draft4Validator, ValidationError, draft4_format_checker -from jsonschema.validators import extend -from packaging.version import Version -from werkzeug.datastructures import FileStorage - -from ..exceptions import (BadRequestProblem, ExtraParameterProblem, - UnsupportedMediaTypeProblem) -from ..http_facts import FORM_CONTENT_TYPES -from ..json_schema import Draft4RequestValidator, Draft4ResponseValidator -from ..lifecycle import FiretailResponse -from ..utils import all_json, boolean, is_json_mimetype, is_null, is_nullable - -_jsonschema_3_or_newer = Version(version("jsonschema")) >= Version("3.0.0") - -logger = logging.getLogger('firetail.decorators.validation') - -TYPE_MAP = { - 'integer': int, - 'number': float, - 'boolean': boolean, - 'object': dict -} - - -class TypeValidationError(Exception): - def __init__(self, schema_type, parameter_type, parameter_name): - """ - Exception raise when type validation fails - - :type schema_type: str - :type parameter_type: str - :type parameter_name: str - :return: - """ - self.schema_type = schema_type - self.parameter_type = parameter_type - self.parameter_name = parameter_name - - def __str__(self): - msg = "Wrong type, expected '{schema_type}' for {parameter_type} parameter '{parameter_name}'" - return msg.format(**vars(self)) - - -def coerce_type(param, value, parameter_type, parameter_name=None): - - def make_type(value, type_literal): - type_func = TYPE_MAP.get(type_literal) - return type_func(value) - - param_schema = param.get("schema", param) - if is_nullable(param_schema) and is_null(value): - return None - - param_type = param_schema.get('type') - parameter_name = parameter_name if parameter_name else param.get('name') - if param_type == "array": - converted_params = [] - if parameter_type == "header": - value = value.split(',') - for v in value: - try: - converted = make_type(v, param_schema["items"]["type"]) - except (ValueError, TypeError): - converted = v - converted_params.append(converted) - return converted_params - elif param_type == 'object': - if param_schema.get('properties'): - def cast_leaves(d, schema): - if type(d) is not dict: - try: - return make_type(d, schema['type']) - except (ValueError, TypeError): - return d - for k, v in d.items(): - if k in schema['properties']: - d[k] = cast_leaves(v, schema['properties'][k]) - return d - - return cast_leaves(value, param_schema) - return value - else: - try: - return make_type(value, param_type) - except ValueError: - raise TypeValidationError( - param_type, parameter_type, parameter_name) - except TypeError: - return value - - -def validate_parameter_list(request_params, spec_params): - request_params = set(request_params) - spec_params = set(spec_params) - - return request_params.difference(spec_params) - - -class RequestBodyValidator: - - def __init__(self, schema, consumes, api, is_null_value_valid=False, validator=None, - strict_validation=False): - """ - :param schema: The schema of the request body - :param consumes: The list of content types the operation consumes - :param is_null_value_valid: Flag to indicate if null is accepted as valid value. - :param validator: Validator class that should be used to validate passed data - against API schema. Default is jsonschema.Draft4Validator. - :type validator: jsonschema.IValidator - :param strict_validation: Flag indicating if parameters not in spec are allowed - """ - self.consumes = consumes - self.schema = schema - self.has_default = schema.get('default', False) - self.is_null_value_valid = is_null_value_valid - validatorClass = validator or Draft4RequestValidator - self.validator = validatorClass( - schema, format_checker=draft4_format_checker) - self.api = api - self.strict_validation = strict_validation - - def validate_formdata_parameter_list(self, request): - request_params = request.form.keys() - spec_params = self.schema.get('properties', {}).keys() - return validate_parameter_list(request_params, spec_params) - - def __call__(self, function): - """ - :type function: types.FunctionType - :rtype: types.FunctionType - """ - - @functools.wraps(function) - def wrapper(request): - if all_json(self.consumes): - data = request.json - - empty_body = not(request.body or request.form or request.files) - if data is None and not empty_body and not self.is_null_value_valid: - try: - ctype_is_json = is_json_mimetype( - request.headers.get("Content-Type", "")) - except ValueError: - ctype_is_json = False - - if ctype_is_json: - # Content-Type is json but actual body was not parsed - raise BadRequestProblem( - detail="Request body is not valid JSON") - else: - # the body has contents that were not parsed as JSON - raise UnsupportedMediaTypeProblem( - detail="Invalid Content-type ({content_type}), expected JSON data".format( - content_type=request.headers.get( - "Content-Type", "") - )) - - logger.debug("%s validating schema...", request.url) - if data is not None or not self.has_default: - self.validate_schema(data, request.url) - elif self.consumes[0] in FORM_CONTENT_TYPES: - data = dict(request.form.items()) or ( - request.body if len(request.body) > 0 else {}) - # validator expects string.. - data.update(dict.fromkeys(request.files, '')) - logger.debug('%s validating schema...', request.url) - - if self.strict_validation: - formdata_errors = self.validate_formdata_parameter_list( - request) - if formdata_errors: - raise ExtraParameterProblem(formdata_errors, []) - - if data: - props = self.schema.get("properties", {}) - errs = [] - for k, param_defn in props.items(): - if k in data: - try: - data[k] = coerce_type( - param_defn, data[k], 'requestBody', k) - except TypeValidationError as e: - errs += [str(e)] - if errs: - raise BadRequestProblem(detail=errs) - - self.validate_schema(data, request.url) - - response = function(request) - return response - - return wrapper - - @classmethod - def _error_path_message(cls, exception): - error_path = '.'.join(str(item) for item in exception.path) - error_path_msg = f" - '{error_path}'" if error_path else "" - return error_path_msg - - def validate_schema(self, data, url): - # type: (dict, AnyStr) -> Union[FiretailResponse, None] - if self.is_null_value_valid and is_null(data): - return None - - try: - self.validator.validate(data) - except ValidationError as exception: - error_path_msg = self._error_path_message(exception=exception) - logger.error( - "{url} validation error: {error}{error_path_msg}".format( - url=url, error=exception.message, - error_path_msg=error_path_msg), - extra={'validator': 'body'}) - raise BadRequestProblem(detail="{message}{error_path_msg}".format( - message=exception.message, - error_path_msg=error_path_msg)) - - return None - - -class ResponseBodyValidator: - def __init__(self, schema, validator=None): - """ - :param schema: The schema of the response body - :param validator: Validator class that should be used to validate passed data - against API schema. Default is Draft4ResponseValidator. - :type validator: jsonschema.IValidator - """ - ValidatorClass = validator or Draft4ResponseValidator - self.validator = ValidatorClass( - schema, format_checker=draft4_format_checker) - - def validate_schema(self, data, url): - # type: (dict, AnyStr) -> Union[FiretailResponse, None] - try: - self.validator.validate(data) - except ValidationError as exception: - logger.error("{url} validation error: {error}".format(url=url, - error=exception), - extra={'validator': 'response'}) - raise exception - - return None - - -class ParameterValidator: - def __init__(self, parameters, api, strict_validation=False): - """ - :param parameters: List of request parameter dictionaries - :param api: api that the validator is attached to - :param strict_validation: Flag indicating if parameters not in spec are allowed - """ - self.parameters = collections.defaultdict(list) - for p in parameters: - self.parameters[p['in']].append(p) - - self.api = api - self.strict_validation = strict_validation - - @staticmethod - def validate_parameter(parameter_type, value, param, param_name=None): - if value is not None: - if is_nullable(param) and is_null(value): - return - - try: - converted_value = coerce_type( - param, value, parameter_type, param_name) - except TypeValidationError as e: - return str(e) - - param = copy.deepcopy(param) - param = param.get('schema', param) - if 'required' in param: - del param['required'] - try: - if parameter_type == 'formdata' and param.get('type') == 'file': - if _jsonschema_3_or_newer: - extend( - Draft4Validator, - type_checker=Draft4Validator.TYPE_CHECKER.redefine( - "file", - lambda checker, instance: isinstance( - instance, FileStorage) - ) - )(param, format_checker=draft4_format_checker).validate(converted_value) - else: - Draft4Validator( - param, - format_checker=draft4_format_checker, - types={'file': FileStorage}).validate(converted_value) - else: - Draft4Validator( - param, format_checker=draft4_format_checker).validate(converted_value) - except ValidationError as exception: - debug_msg = 'Error while converting value {converted_value} from param ' \ - '{type_converted_value} of type real type {param_type} to the declared type {param}' - fmt_params = dict( - converted_value=str(converted_value), - type_converted_value=type(converted_value), - param_type=param.get('type'), - param=param - ) - logger.info(debug_msg.format(**fmt_params)) - return str(exception) - - elif param.get('required'): - return "Missing {parameter_type} parameter '{param[name]}'".format(**locals()) - - def validate_query_parameter_list(self, request): - request_params = request.query.keys() - spec_params = [x['name'] for x in self.parameters.get('query', [])] - return validate_parameter_list(request_params, spec_params) - - def validate_formdata_parameter_list(self, request): - request_params = request.form.keys() - if 'formData' in self.parameters: # Swagger 2: - spec_params = [x['name'] for x in self.parameters['formData']] - else: # OAS 3 - return set() - return validate_parameter_list(request_params, spec_params) - - def validate_query_parameter(self, param, request): - """ - Validate a single query parameter (request.args in Flask) - - :type param: dict - :rtype: str - """ - val = request.query.get(param['name']) - return self.validate_parameter('query', val, param) - - def validate_path_parameter(self, param, request): - val = request.path_params.get(param['name'].replace('-', '_')) - return self.validate_parameter('path', val, param) - - def validate_header_parameter(self, param, request): - val = request.headers.get(param['name']) - return self.validate_parameter('header', val, param) - - def validate_cookie_parameter(self, param, request): - val = request.cookies.get(param['name']) - return self.validate_parameter('cookie', val, param) - - def validate_formdata_parameter(self, param_name, param, request): - if param.get('type') == 'file' or param.get('format') == 'binary': - val = request.files.get(param_name) - else: - val = request.form.get(param_name) - - return self.validate_parameter('formdata', val, param) - - def __call__(self, function): - """ - :type function: types.FunctionType - :rtype: types.FunctionType - """ - - @functools.wraps(function) - def wrapper(request): - logger.debug("%s validating parameters...", request.url) - - if self.strict_validation: - query_errors = self.validate_query_parameter_list(request) - formdata_errors = self.validate_formdata_parameter_list( - request) - - if formdata_errors or query_errors: - raise ExtraParameterProblem(formdata_errors, query_errors) - - for param in self.parameters.get('query', []): - error = self.validate_query_parameter(param, request) - if error: - raise BadRequestProblem(detail=error) - - for param in self.parameters.get('path', []): - error = self.validate_path_parameter(param, request) - if error: - raise BadRequestProblem(detail=error) - - for param in self.parameters.get('header', []): - error = self.validate_header_parameter(param, request) - if error: - raise BadRequestProblem(detail=error) - - for param in self.parameters.get('cookie', []): - error = self.validate_cookie_parameter(param, request) - if error: - raise BadRequestProblem(detail=error) - - for param in self.parameters.get('formData', []): - error = self.validate_formdata_parameter( - param["name"], param, request) - if error: - raise BadRequestProblem(detail=error) - - return function(request) - - return wrapper +""" +This module defines view function decorators to validate request and response parameters and bodies. +""" + +import collections +import copy +import functools +import logging +from typing import AnyStr, Union + +try: + from importlib.metadata import version +except ImportError: + from importlib_metadata import version + +from jsonschema import Draft4Validator, ValidationError, draft4_format_checker +from jsonschema.validators import extend +from packaging.version import Version +from werkzeug.datastructures import FileStorage + +from ..exceptions import ( + BadRequestProblem, + ExtraParameterProblem, + UnsupportedMediaTypeProblem, +) +from ..http_facts import FORM_CONTENT_TYPES +from ..json_schema import Draft4RequestValidator, Draft4ResponseValidator +from ..lifecycle import FiretailResponse +from ..utils import all_json, boolean, is_json_mimetype, is_null, is_nullable + +_jsonschema_3_or_newer = Version(version("jsonschema")) >= Version("3.0.0") + +logger = logging.getLogger('firetail.decorators.validation') + +TYPE_MAP = { + 'integer': int, + 'number': float, + 'boolean': boolean, + 'object': dict +} + + +class TypeValidationError(Exception): + def __init__(self, schema_type, parameter_type, parameter_name): + """ + Exception raise when type validation fails + + :type schema_type: str + :type parameter_type: str + :type parameter_name: str + :return: + """ + self.schema_type = schema_type + self.parameter_type = parameter_type + self.parameter_name = parameter_name + + def __str__(self): + msg = "Wrong type, expected '{schema_type}' for {parameter_type} parameter '{parameter_name}'" + return msg.format(**vars(self)) + + +def coerce_type(param, value, parameter_type, parameter_name=None): + + def make_type(value, type_literal): + type_func = TYPE_MAP.get(type_literal) + return type_func(value) + + param_schema = param.get("schema", param) + if is_nullable(param_schema) and is_null(value): + return None + + param_type = param_schema.get('type') + parameter_name = parameter_name if parameter_name else param.get('name') + if param_type == "array": + converted_params = [] + if parameter_type == "header": + value = value.split(',') + for v in value: + try: + converted = make_type(v, param_schema["items"]["type"]) + except (ValueError, TypeError): + converted = v + converted_params.append(converted) + return converted_params + elif param_type == 'object': + if param_schema.get('properties'): + def cast_leaves(d, schema): + if type(d) is not dict: + try: + return make_type(d, schema['type']) + except (ValueError, TypeError): + return d + for k, v in d.items(): + if k in schema['properties']: + d[k] = cast_leaves(v, schema['properties'][k]) + return d + + return cast_leaves(value, param_schema) + return value + else: + try: + return make_type(value, param_type) + except ValueError: + raise TypeValidationError(param_type, parameter_type, parameter_name) + except TypeError: + return value + + +def validate_parameter_list(request_params, spec_params): + request_params = set(request_params) + spec_params = set(spec_params) + + return request_params.difference(spec_params) + + +class RequestBodyValidator: + + def __init__(self, schema, consumes, api, is_null_value_valid=False, validator=None, + strict_validation=False): + """ + :param schema: The schema of the request body + :param consumes: The list of content types the operation consumes + :param is_null_value_valid: Flag to indicate if null is accepted as valid value. + :param validator: Validator class that should be used to validate passed data + against API schema. Default is jsonschema.Draft4Validator. + :type validator: jsonschema.IValidator + :param strict_validation: Flag indicating if parameters not in spec are allowed + """ + self.consumes = consumes + self.schema = schema + self.has_default = schema.get('default', False) + self.is_null_value_valid = is_null_value_valid + validatorClass = validator or Draft4RequestValidator + self.validator = validatorClass(schema, format_checker=draft4_format_checker) + self.api = api + self.strict_validation = strict_validation + + def validate_formdata_parameter_list(self, request): + request_params = request.form.keys() + spec_params = self.schema.get('properties', {}).keys() + return validate_parameter_list(request_params, spec_params) + + def __call__(self, function): + """ + :type function: types.FunctionType + :rtype: types.FunctionType + """ + + @functools.wraps(function) + def wrapper(request): + if all_json(self.consumes): + data = request.json + + empty_body = not(request.body or request.form or request.files) + if data is None and not empty_body and not self.is_null_value_valid: + try: + ctype_is_json = is_json_mimetype(request.headers.get("Content-Type", "")) + except ValueError: + ctype_is_json = False + + if ctype_is_json: + # Content-Type is json but actual body was not parsed + raise BadRequestProblem(detail="Request body is not valid JSON") + else: + # the body has contents that were not parsed as JSON + raise UnsupportedMediaTypeProblem( + detail="Invalid Content-type ({content_type}), expected JSON data".format( + content_type=request.headers.get("Content-Type", "") + )) + + logger.debug("%s validating schema...", request.url) + if data is not None or not self.has_default: + self.validate_schema(data, request.url) + elif self.consumes[0] in FORM_CONTENT_TYPES: + data = dict(request.form.items()) or (request.body if len(request.body) > 0 else {}) + data.update(dict.fromkeys(request.files, '')) # validator expects string.. + logger.debug('%s validating schema...', request.url) + + if self.strict_validation: + formdata_errors = self.validate_formdata_parameter_list(request) + if formdata_errors: + raise ExtraParameterProblem(formdata_errors, []) + + if data: + props = self.schema.get("properties", {}) + errs = [] + for k, param_defn in props.items(): + if k in data: + try: + data[k] = coerce_type(param_defn, data[k], 'requestBody', k) + except TypeValidationError as e: + errs += [str(e)] + print(errs) + if errs: + raise BadRequestProblem(detail=errs) + + self.validate_schema(data, request.url) + + response = function(request) + return response + + return wrapper + + @classmethod + def _error_path_message(cls, exception): + error_path = '.'.join(str(item) for item in exception.path) + error_path_msg = f" - '{error_path}'" if error_path else "" + return error_path_msg + + def validate_schema(self, data, url): + # type: (dict, AnyStr) -> Union[FiretailResponse, None] + if self.is_null_value_valid and is_null(data): + return None + + try: + self.validator.validate(data) + except ValidationError as exception: + error_path_msg = self._error_path_message(exception=exception) + logger.error( + "{url} validation error: {error}{error_path_msg}".format( + url=url, error=exception.message, + error_path_msg=error_path_msg), + extra={'validator': 'body'}) + raise BadRequestProblem(detail="{message}{error_path_msg}".format( + message=exception.message, + error_path_msg=error_path_msg)) + + return None + + +class ResponseBodyValidator: + def __init__(self, schema, validator=None): + """ + :param schema: The schema of the response body + :param validator: Validator class that should be used to validate passed data + against API schema. Default is Draft4ResponseValidator. + :type validator: jsonschema.IValidator + """ + ValidatorClass = validator or Draft4ResponseValidator + self.validator = ValidatorClass(schema, format_checker=draft4_format_checker) + + def validate_schema(self, data, url): + # type: (dict, AnyStr) -> Union[FiretailResponse, None] + try: + self.validator.validate(data) + except ValidationError as exception: + logger.error("{url} validation error: {error}".format(url=url, + error=exception), + extra={'validator': 'response'}) + raise exception + + return None + + +class ParameterValidator: + def __init__(self, parameters, api, strict_validation=False): + """ + :param parameters: List of request parameter dictionaries + :param api: api that the validator is attached to + :param strict_validation: Flag indicating if parameters not in spec are allowed + """ + self.parameters = collections.defaultdict(list) + for p in parameters: + self.parameters[p['in']].append(p) + + self.api = api + self.strict_validation = strict_validation + + @staticmethod + def validate_parameter(parameter_type, value, param, param_name=None): + if value is not None: + if is_nullable(param) and is_null(value): + return + + try: + converted_value = coerce_type(param, value, parameter_type, param_name) + except TypeValidationError as e: + return str(e) + + param = copy.deepcopy(param) + param = param.get('schema', param) + if 'required' in param: + del param['required'] + try: + if parameter_type == 'formdata' and param.get('type') == 'file': + if _jsonschema_3_or_newer: + extend( + Draft4Validator, + type_checker=Draft4Validator.TYPE_CHECKER.redefine( + "file", + lambda checker, instance: isinstance(instance, FileStorage) + ) + )(param, format_checker=draft4_format_checker).validate(converted_value) + else: + Draft4Validator( + param, + format_checker=draft4_format_checker, + types={'file': FileStorage}).validate(converted_value) + else: + Draft4Validator( + param, format_checker=draft4_format_checker).validate(converted_value) + except ValidationError as exception: + debug_msg = 'Error while converting value {converted_value} from param ' \ + '{type_converted_value} of type real type {param_type} to the declared type {param}' + fmt_params = dict( + converted_value=str(converted_value), + type_converted_value=type(converted_value), + param_type=param.get('type'), + param=param + ) + logger.info(debug_msg.format(**fmt_params)) + return str(exception) + + elif param.get('required'): + return "Missing {parameter_type} parameter '{param[name]}'".format(**locals()) + + def validate_query_parameter_list(self, request): + request_params = request.query.keys() + spec_params = [x['name'] for x in self.parameters.get('query', [])] + return validate_parameter_list(request_params, spec_params) + + def validate_formdata_parameter_list(self, request): + request_params = request.form.keys() + if 'formData' in self.parameters: # Swagger 2: + spec_params = [x['name'] for x in self.parameters['formData']] + else: # OAS 3 + return set() + return validate_parameter_list(request_params, spec_params) + + def validate_query_parameter(self, param, request): + """ + Validate a single query parameter (request.args in Flask) + + :type param: dict + :rtype: str + """ + val = request.query.get(param['name']) + return self.validate_parameter('query', val, param) + + def validate_path_parameter(self, param, request): + val = request.path_params.get(param['name'].replace('-', '_')) + return self.validate_parameter('path', val, param) + + def validate_header_parameter(self, param, request): + val = request.headers.get(param['name']) + return self.validate_parameter('header', val, param) + + def validate_cookie_parameter(self, param, request): + val = request.cookies.get(param['name']) + return self.validate_parameter('cookie', val, param) + + def validate_formdata_parameter(self, param_name, param, request): + if param.get('type') == 'file' or param.get('format') == 'binary': + val = request.files.get(param_name) + else: + val = request.form.get(param_name) + + return self.validate_parameter('formdata', val, param) + + def __call__(self, function): + """ + :type function: types.FunctionType + :rtype: types.FunctionType + """ + + @functools.wraps(function) + def wrapper(request): + logger.debug("%s validating parameters...", request.url) + + if self.strict_validation: + query_errors = self.validate_query_parameter_list(request) + formdata_errors = self.validate_formdata_parameter_list(request) + + if formdata_errors or query_errors: + raise ExtraParameterProblem(formdata_errors, query_errors) + + for param in self.parameters.get('query', []): + error = self.validate_query_parameter(param, request) + if error: + raise BadRequestProblem(detail=error) + + for param in self.parameters.get('path', []): + error = self.validate_path_parameter(param, request) + if error: + raise BadRequestProblem(detail=error) + + for param in self.parameters.get('header', []): + error = self.validate_header_parameter(param, request) + if error: + raise BadRequestProblem(detail=error) + + for param in self.parameters.get('cookie', []): + error = self.validate_cookie_parameter(param, request) + if error: + raise BadRequestProblem(detail=error) + + for param in self.parameters.get('formData', []): + error = self.validate_formdata_parameter(param["name"], param, request) + if error: + raise BadRequestProblem(detail=error) + + return function(request) + + return wrapper diff --git a/firetail/exceptions.py b/firetail/exceptions.py index a5509c3..76758ba 100644 --- a/firetail/exceptions.py +++ b/firetail/exceptions.py @@ -1,147 +1,147 @@ -""" -This module defines Exception classes used by Firetail to generate a proper response. -""" - -import warnings - -from jsonschema.exceptions import ValidationError -from werkzeug.exceptions import Forbidden, Unauthorized - -from .problem import problem - - -class FiretailException(Exception): - pass - - -class ProblemException(FiretailException): - def __init__(self, status=400, title=None, detail=None, type=None, - instance=None, headers=None, ext=None): - """ - This exception holds arguments that are going to be passed to the - `firetail.problem` function to generate a proper response. - """ - self.status = status - self.title = title - self.detail = detail - self.type = type - self.instance = instance - self.headers = headers - self.ext = ext - - def to_problem(self): - warnings.warn( - "'to_problem' is planned to be removed in a future release. " - "Call firetail.problem.problem(..) instead to maintain the existing error response.", DeprecationWarning) - return problem(status=self.status, title=self.title, detail=self.detail, - type=self.type, instance=self.instance, headers=self.headers, - ext=self.ext) - - -class ResolverError(LookupError): - def __init__(self, reason='Unknown reason', exc_info=None): - """ - :param reason: Reason why the resolver failed. - :type reason: str - :param exc_info: If specified, gives details of the original exception - as returned by sys.exc_info() - :type exc_info: tuple | None - """ - self.reason = reason - self.exc_info = exc_info - - def __str__(self): # pragma: no cover - return f'' - - def __repr__(self): # pragma: no cover - return f'' - - -class InvalidSpecification(FiretailException, ValidationError): - pass - - -class NonConformingResponse(ProblemException): - def __init__(self, reason='Unknown Reason', message=None): - """ - :param reason: Reason why the response did not conform to the specification - :type reason: str - """ - super().__init__(status=500, title=reason, detail=message) - self.reason = reason - self.message = message - - def __str__(self): # pragma: no cover - return f'' - - def __repr__(self): # pragma: no cover - return f'' - - -class AuthenticationProblem(ProblemException): - - def __init__(self, status, title, detail): - super().__init__(status=status, title=title, detail=detail) - - -class ResolverProblem(ProblemException): - - def __init__(self, status, title, detail): - super().__init__(status=status, title=title, detail=detail) - - -class BadRequestProblem(ProblemException): - - def __init__(self, title='Bad Request', detail=None): - super().__init__(status=400, title=title, detail=detail) - - -class UnsupportedMediaTypeProblem(ProblemException): - - def __init__(self, title="Unsupported Media Type", detail=None): - super().__init__(status=415, title=title, detail=detail) - - -class NonConformingResponseBody(NonConformingResponse): - def __init__(self, message, reason="Response body does not conform to specification"): - super().__init__(reason=reason, message=message) - - -class NonConformingResponseHeaders(NonConformingResponse): - def __init__(self, message, reason="Response headers do not conform to specification"): - super().__init__(reason=reason, message=message) - - -class OAuthProblem(Unauthorized): - pass - - -class OAuthResponseProblem(OAuthProblem): - def __init__(self, token_response, **kwargs): - self.token_response = token_response - super().__init__(**kwargs) - - -class OAuthScopeProblem(Forbidden): - def __init__(self, token_scopes, required_scopes, **kwargs): - self.required_scopes = required_scopes - self.token_scopes = token_scopes - - super().__init__(**kwargs) - - -class ExtraParameterProblem(ProblemException): - def __init__(self, formdata_parameters, query_parameters, title=None, detail=None, **kwargs): - self.extra_formdata = formdata_parameters - self.extra_query = query_parameters - - # This keep backwards compatibility with the old returns - if detail is None: - if self.extra_query: - detail = "Extra {parameter_type} parameter(s) {extra_params} not in spec"\ - .format(parameter_type='query', extra_params=', '.join(self.extra_query)) - elif self.extra_formdata: - detail = "Extra {parameter_type} parameter(s) {extra_params} not in spec"\ - .format(parameter_type='formData', extra_params=', '.join(self.extra_formdata)) - - super().__init__(title=title, detail=detail, **kwargs) +""" +This module defines Exception classes used by Firetail to generate a proper response. +""" + +import warnings + +from jsonschema.exceptions import ValidationError +from werkzeug.exceptions import Forbidden, Unauthorized + +from .problem import problem + + +class FiretailException(Exception): + pass + + +class ProblemException(FiretailException): + def __init__(self, status=400, title=None, detail=None, type=None, + instance=None, headers=None, ext=None): + """ + This exception holds arguments that are going to be passed to the + `firetail.problem` function to generate a proper response. + """ + self.status = status + self.title = title + self.detail = detail + self.type = type + self.instance = instance + self.headers = headers + self.ext = ext + + def to_problem(self): + warnings.warn( + "'to_problem' is planned to be removed in a future release. " + "Call firetail.problem.problem(..) instead to maintain the existing error response.", DeprecationWarning) + return problem(status=self.status, title=self.title, detail=self.detail, + type=self.type, instance=self.instance, headers=self.headers, + ext=self.ext) + + +class ResolverError(LookupError): + def __init__(self, reason='Unknown reason', exc_info=None): + """ + :param reason: Reason why the resolver failed. + :type reason: str + :param exc_info: If specified, gives details of the original exception + as returned by sys.exc_info() + :type exc_info: tuple | None + """ + self.reason = reason + self.exc_info = exc_info + + def __str__(self): # pragma: no cover + return f'' + + def __repr__(self): # pragma: no cover + return f'' + + +class InvalidSpecification(FiretailException, ValidationError): + pass + + +class NonConformingResponse(ProblemException): + def __init__(self, reason='Unknown Reason', message=None): + """ + :param reason: Reason why the response did not conform to the specification + :type reason: str + """ + super().__init__(status=500, title=reason, detail=message) + self.reason = reason + self.message = message + + def __str__(self): # pragma: no cover + return f'' + + def __repr__(self): # pragma: no cover + return f'' + + +class AuthenticationProblem(ProblemException): + + def __init__(self, status, title, detail): + super().__init__(status=status, title=title, detail=detail) + + +class ResolverProblem(ProblemException): + + def __init__(self, status, title, detail): + super().__init__(status=status, title=title, detail=detail) + + +class BadRequestProblem(ProblemException): + + def __init__(self, title='Bad Request', detail=None): + super().__init__(status=400, title=title, detail=detail) + + +class UnsupportedMediaTypeProblem(ProblemException): + + def __init__(self, title="Unsupported Media Type", detail=None): + super().__init__(status=415, title=title, detail=detail) + + +class NonConformingResponseBody(NonConformingResponse): + def __init__(self, message, reason="Response body does not conform to specification"): + super().__init__(reason=reason, message=message) + + +class NonConformingResponseHeaders(NonConformingResponse): + def __init__(self, message, reason="Response headers do not conform to specification"): + super().__init__(reason=reason, message=message) + + +class OAuthProblem(Unauthorized): + pass + + +class OAuthResponseProblem(OAuthProblem): + def __init__(self, token_response, **kwargs): + self.token_response = token_response + super().__init__(**kwargs) + + +class OAuthScopeProblem(Forbidden): + def __init__(self, token_scopes, required_scopes, **kwargs): + self.required_scopes = required_scopes + self.token_scopes = token_scopes + + super().__init__(**kwargs) + + +class ExtraParameterProblem(ProblemException): + def __init__(self, formdata_parameters, query_parameters, title=None, detail=None, **kwargs): + self.extra_formdata = formdata_parameters + self.extra_query = query_parameters + + # This keep backwards compatibility with the old returns + if detail is None: + if self.extra_query: + detail = "Extra {parameter_type} parameter(s) {extra_params} not in spec"\ + .format(parameter_type='query', extra_params=', '.join(self.extra_query)) + elif self.extra_formdata: + detail = "Extra {parameter_type} parameter(s) {extra_params} not in spec"\ + .format(parameter_type='formData', extra_params=', '.join(self.extra_formdata)) + + super().__init__(title=title, detail=detail, **kwargs) diff --git a/firetail/handlers.py b/firetail/handlers.py index ea21122..7d7d486 100644 --- a/firetail/handlers.py +++ b/firetail/handlers.py @@ -1,206 +1,218 @@ -""" -This module defines error handlers, operations that produce proper response problems. -""" - -# import datetime -import json -import logging -import logging.handlers -import sys -import traceback - -from .exceptions import (AuthenticationProblem, FiretailException, - ResolverProblem) -from .logger import get_stdout_logger -from .operations.secure import SecureOperation -from .sender import FiretailSender - -logger = logging.getLogger('firetail.handlers') - -RESOLVER_ERROR_ENDPOINT_RANDOM_DIGITS = 6 - - -class AuthErrorHandler(SecureOperation): - """ - Wraps an error with authentication. - """ - - def __init__(self, api, exception, security, security_definitions): - """ - This class uses the exception instance to produce the proper response problem in case the - request is authenticated. - - :param exception: the exception to be wrapped with authentication - :type exception: werkzeug.exceptions.HTTPException - :param security: list of security rules the application uses by default - :type security: list - :param security_definitions: `Security Definitions Object - `_ - :type security_definitions: dict - """ - self.exception = exception - super().__init__(api, security, security_definitions) - - @property - def function(self): - """ - Configured error auth handler. - """ - security_decorator = self.security_decorator - logger.debug('... Adding security decorator (%r)', security_decorator, extra=vars(self)) - function = self.handle - function = security_decorator(function) - function = self._request_response_decorator(function) - return function - - def handle(self, *args, **kwargs): - """ - Actual handler for the execution after authentication. - """ - raise AuthenticationProblem( - title=self.exception.name, - detail=self.exception.description, - status=self.exception.code - ) - - -class ResolverErrorHandler(SecureOperation): - """ - Handler for responding to ResolverError. - """ - - def __init__(self, api, status_code, exception, security, security_definitions): - self.status_code = status_code - self.exception = exception - super().__init__(api, security, security_definitions) - - @property - def function(self): - return self.handle - - def handle(self, *args, **kwargs): - raise ResolverProblem( - title='Not Implemented', - detail=self.exception.reason, - status=self.status_code - ) - - @property - def operation_id(self): - return "noop" - - @property - def randomize_endpoint(self): - return RESOLVER_ERROR_ENDPOINT_RANDOM_DIGITS - - def get_path_parameter_types(self): - return {} - - -class FiretailHandler(logging.Handler): - - def __init__(self, - api_key, - token, - url, - custom_backend=False, - firetail_type="python", - logs_drain_timeout=3, - debug=False, - backup_logs=True, - network_timeout=10.0, - retries_no=4, - retry_timeout=2): - - if not token and not custom_backend: - raise FiretailException('firetail Token must be provided') - - self.firetail_type = firetail_type - - self.firetail_sender = FiretailSender( - token=token, - url=url, - api_key=api_key, - logs_drain_timeout=logs_drain_timeout, - debug=debug, - backup_logs=backup_logs, - network_timeout=network_timeout, - number_of_retries=retries_no, - retry_timeout=retry_timeout) - logging.Handler.__init__(self) - - def __del__(self): - del self.firetail_sender - - def extra_fields(self, message): - - not_allowed_keys = ( - 'args', 'asctime', 'created', 'exc_info', 'stack_info', 'exc_text', - 'filename', 'funcName', 'levelname', 'levelno', 'lineno', 'module', - 'msecs', 'msecs', 'message', 'msg', 'name', 'pathname', 'process', - 'processName', 'relativeCreated', 'thread', 'threadName') - - if sys.version_info < (3, 0): - # long and basestring don't exist in py3 so, NOQA - var_type = (basestring, bool, dict, float, # NOQA - int, long, list, type(None)) # NOQA - else: - var_type = (str, bool, dict, float, int, list, type(None)) - - extra_fields = {} - - for key, value in message.__dict__.items(): - if key not in not_allowed_keys: - if isinstance(value, var_type): - extra_fields[key] = value - else: - extra_fields[key] = repr(value) - - return extra_fields - - def flush(self): - self.firetail_sender.flush() - - def format(self, record): - message = super(FiretailHandler, self).format(record) - try: - return json.loads(message) - except (TypeError, ValueError): - return message - - def format_exception(self, exc_info): - return '\n'.join(traceback.format_exception(*exc_info)) - - def format_message(self, message): - # now = datetime.datetime.utcnow() - # timestamp = now.strftime('%Y-%m-%dT%H:%M:%S') + \ - # '.%03d' % (now.microsecond / 1000) + 'Z' - - # return_json = { - # 'logger': message.name, - # 'line_number': message.lineno, - # 'path_name': message.pathname, - # 'log_level': message.levelname, - # 'type': self.firetail_type, - # 'message': message.getMessage(), - # '@timestamp': timestamp - # } - try: - payload = json.loads(message.getMessage()) - except json.decoder.JSONDecodeError: - return {'ignore': True} - - # requiredInBody = ['req', 'res'] - # for item in requiredInBody: - # if item not in payload: - # return {'ignore': True} - - # return_json = payload - return payload - - def emit(self, record): - message = self.format_message(record) - self.stdout_logger = get_stdout_logger(False) - # self.stdout_logger.info(record.getMessage()) - if 'ignore' not in message: - self.firetail_sender.append(message) +""" +This module defines error handlers, operations that produce proper response problems. +""" + +import json +import logging +import logging.handlers +import traceback + +from .exceptions import AuthenticationProblem, FiretailException, ResolverProblem +from .logger import get_stdout_logger +from .operations.secure import SecureOperation +from .sender import FiretailSender + +logger = logging.getLogger('firetail.handlers') + +RESOLVER_ERROR_ENDPOINT_RANDOM_DIGITS = 6 + + +class AuthErrorHandler(SecureOperation): + """ + Wraps an error with authentication. + """ + + def __init__(self, api, exception, security, security_definitions): + """ + This class uses the exception instance to produce the proper response problem in case the + request is authenticated. + + :param exception: the exception to be wrapped with authentication + :type exception: werkzeug.exceptions.HTTPException + :param security: list of security rules the application uses by default + :type security: list + :param security_definitions: `Security Definitions Object + `_ + :type security_definitions: dict + """ + self.exception = exception + super().__init__(api, security, security_definitions) + + @property + def function(self): + """ + Configured error auth handler. + """ + security_decorator = self.security_decorator + logger.debug('... Adding security decorator (%r)', security_decorator, extra=vars(self)) + function = self.handle + function = security_decorator(function) + function = self._request_response_decorator(function) + return function + + def handle(self, *args, **kwargs): + """ + Actual handler for the execution after authentication. + """ + raise AuthenticationProblem( + title=self.exception.name, + detail=self.exception.description, + status=self.exception.code + ) + + +class ResolverErrorHandler(SecureOperation): + """ + Handler for responding to ResolverError. + """ + + def __init__(self, api, status_code, exception, security, security_definitions): + self.status_code = status_code + self.exception = exception + super().__init__(api, security, security_definitions) + + @property + def function(self): + return self.handle + + def handle(self, *args, **kwargs): + raise ResolverProblem( + title='Not Implemented', + detail=self.exception.reason, + status=self.status_code + ) + + @property + def operation_id(self): + return "noop" + + @property + def randomize_endpoint(self): + return RESOLVER_ERROR_ENDPOINT_RANDOM_DIGITS + + def get_path_parameter_types(self): + return {} + + +class FiretailHandler(logging.Handler): + def __init__( + self, + token, + url, + custom_backend=False, + firetail_type="python", + logs_drain_timeout=3, + debug=False, + backup_logs=True, + network_timeout=10.0, + retries_no=4, + retry_timeout=2, + ): + + if not token and not custom_backend: + raise FiretailException("firetail Token must be provided") + + self.firetail_type = firetail_type + + self.firetail_sender = FiretailSender( + token=token, + url=url, + logs_drain_timeout=logs_drain_timeout, + debug=debug, + backup_logs=backup_logs, + network_timeout=network_timeout, + number_of_retries=retries_no, + retry_timeout=retry_timeout, + ) + logging.Handler.__init__(self) + + def __del__(self): + del self.firetail_sender + + def extra_fields(self, message): + + not_allowed_keys = ( + "args", + "asctime", + "created", + "exc_info", + "stack_info", + "exc_text", + "filename", + "funcName", + "levelname", + "levelno", + "lineno", + "module", + "msecs", + "msecs", + "message", + "msg", + "name", + "pathname", + "process", + "processName", + "relativeCreated", + "thread", + "threadName", + ) + + var_type = (str, bool, dict, float, int, list, type(None)) + + extra_fields = {} + + for key, value in message.__dict__.items(): + if key not in not_allowed_keys: + if isinstance(value, var_type): + extra_fields[key] = value + else: + extra_fields[key] = repr(value) + + return extra_fields + + def flush(self): + self.firetail_sender.flush() + + def format(self, record): + message = super(FiretailHandler, self).format(record) + try: + return json.loads(message) + except (TypeError, ValueError): + return message + + def format_exception(self, exc_info): + return "\n".join(traceback.format_exception(*exc_info)) + + def format_message(self, message): + # now = datetime.datetime.utcnow() + # timestamp = now.strftime('%Y-%m-%dT%H:%M:%S') + \ + # '.%03d' % (now.microsecond / 1000) + 'Z' + + # return_json = { + # 'logger': message.name, + # 'line_number': message.lineno, + # 'path_name': message.pathname, + # 'log_level': message.levelname, + # 'type': self.firetail_type, + # 'message': message.getMessage(), + # '@timestamp': timestamp + # } + try: + payload = json.loads(message.getMessage()) + except json.decoder.JSONDecodeError: + return {"ignore": True} + + # requiredInBody = ['req', 'res'] + # for item in requiredInBody: + # if item not in payload: + # return {'ignore': True} + + # return_json = payload + return payload + + def emit(self, record): + message = self.format_message(record) + self.stdout_logger = get_stdout_logger(False) + # self.stdout_logger.info(record.getMessage()) + if "ignore" not in message: + self.firetail_sender.append(message) diff --git a/firetail/http_facts.py b/firetail/http_facts.py index dfdff8c..f2c8c46 100644 --- a/firetail/http_facts.py +++ b/firetail/http_facts.py @@ -1,19 +1,19 @@ -""" -This module contains definitions of the HTTP protocol. -""" - -FORM_CONTENT_TYPES = [ - 'application/x-www-form-urlencoded', - 'multipart/form-data' -] - -METHODS = { - "get", - "put", - "post", - "delete", - "options", - "head", - "patch", - "trace" -} +""" +This module contains definitions of the HTTP protocol. +""" + +FORM_CONTENT_TYPES = [ + 'application/x-www-form-urlencoded', + 'multipart/form-data' +] + +METHODS = { + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace" +} diff --git a/firetail/json_schema.py b/firetail/json_schema.py index eb8f59c..ec18323 100644 --- a/firetail/json_schema.py +++ b/firetail/json_schema.py @@ -1,160 +1,160 @@ -""" -Module containing all code related to json schema validation. -""" - -import contextlib -import io -import os -import typing as t -import urllib.parse -import urllib.request -from collections.abc import Mapping -from copy import deepcopy - -import requests -import yaml -from jsonschema import Draft4Validator, RefResolver -from jsonschema.exceptions import RefResolutionError, ValidationError # noqa -from jsonschema.validators import extend - -from .utils import deep_get - - -class ExtendedSafeLoader(yaml.SafeLoader): - """Extends the yaml SafeLoader to coerce all keys to string so the result is valid json.""" - - def __init__(self, stream): - self.original_construct_mapping = self.construct_mapping - self.construct_mapping = self.extended_construct_mapping - super().__init__(stream) - - def extended_construct_mapping(self, node, deep=False): - data = self.original_construct_mapping(node, deep) - return {str(key): data[key] for key in data} - - -class FileHandler: - """Handler to resolve file refs.""" - - def __call__(self, uri): - filepath = self._uri_to_path(uri) - with open(filepath) as fh: - return yaml.load(fh, ExtendedSafeLoader) - - @staticmethod - def _uri_to_path(uri): - parsed = urllib.parse.urlparse(uri) - host = "{0}{0}{mnt}{0}".format(os.path.sep, mnt=parsed.netloc) - return os.path.abspath( - os.path.join(host, urllib.request.url2pathname(parsed.path)) - ) - - -class URLHandler: - """Handler to resolve url refs.""" - - def __call__(self, uri): - response = requests.get(uri) - response.raise_for_status() - - data = io.StringIO(response.text) - with contextlib.closing(data) as fh: - return yaml.load(fh, ExtendedSafeLoader) - - -default_handlers = { - 'http': URLHandler(), - 'https': URLHandler(), - 'file': FileHandler(), -} - - -def resolve_refs(spec, store=None, handlers=None): - """ - Resolve JSON references like {"$ref": } in a spec. - Optionally takes a store, which is a mapping from reference URLs to a - dereferenced objects. Prepopulating the store can avoid network calls. - """ - spec = deepcopy(spec) - store = store or {} - handlers = handlers or default_handlers - resolver = RefResolver('', spec, store, handlers=handlers) - - def _do_resolve(node): - if isinstance(node, Mapping) and '$ref' in node: - path = node['$ref'][2:].split("/") - try: - # resolve known references - node.update(deep_get(spec, path)) - del node['$ref'] - return node - except KeyError: - # resolve external references - with resolver.resolving(node['$ref']) as resolved: - return resolved - elif isinstance(node, Mapping): - for k, v in node.items(): - node[k] = _do_resolve(v) - elif isinstance(node, (list, tuple)): - for i, _ in enumerate(node): - node[i] = _do_resolve(node[i]) - return node - - res = _do_resolve(spec) - return res - - -def allow_nullable(validation_fn: t.Callable) -> t.Callable: - """Extend an existing validation function, so it allows nullable values to be null.""" - - def nullable_validation_fn(validator, to_validate, instance, schema): - if instance is None and (schema.get('x-nullable') is True or schema.get('nullable')): - return - - yield from validation_fn(validator, to_validate, instance, schema) - - return nullable_validation_fn - - -def validate_required(validator, required, instance, schema): - if not validator.is_type(instance, "object"): - return - - for prop in required: - if prop not in instance: - properties = schema.get('properties') - if properties is not None: - subschema = properties.get(prop) - if subschema is not None: - if 'readOnly' in validator.VALIDATORS and subschema.get('readOnly'): - continue - if 'writeOnly' in validator.VALIDATORS and subschema.get('writeOnly'): - continue - if 'x-writeOnly' in validator.VALIDATORS and subschema.get('x-writeOnly') is True: - continue - yield ValidationError("%r is a required property" % prop) - - -def validate_readOnly(validator, ro, instance, schema): - yield ValidationError("Property is read-only") - - -def validate_writeOnly(validator, wo, instance, schema): - yield ValidationError("Property is write-only") - - -NullableTypeValidator = allow_nullable(Draft4Validator.VALIDATORS['type']) -NullableEnumValidator = allow_nullable(Draft4Validator.VALIDATORS['enum']) - -Draft4RequestValidator = extend(Draft4Validator, { - 'type': NullableTypeValidator, - 'enum': NullableEnumValidator, - 'required': validate_required, - 'readOnly': validate_readOnly}) - -Draft4ResponseValidator = extend(Draft4Validator, { - 'type': NullableTypeValidator, - 'enum': NullableEnumValidator, - 'required': validate_required, - 'writeOnly': validate_writeOnly, - 'x-writeOnly': validate_writeOnly}) +""" +Module containing all code related to json schema validation. +""" + +import contextlib +import io +import os +import typing as t +import urllib.parse +import urllib.request +from collections.abc import Mapping +from copy import deepcopy + +import requests +import yaml +from jsonschema import Draft4Validator, RefResolver +from jsonschema.exceptions import RefResolutionError, ValidationError # noqa +from jsonschema.validators import extend + +from .utils import deep_get + + +class ExtendedSafeLoader(yaml.SafeLoader): + """Extends the yaml SafeLoader to coerce all keys to string so the result is valid json.""" + + def __init__(self, stream): + self.original_construct_mapping = self.construct_mapping + self.construct_mapping = self.extended_construct_mapping + super().__init__(stream) + + def extended_construct_mapping(self, node, deep=False): + data = self.original_construct_mapping(node, deep) + return {str(key): data[key] for key in data} + + +class FileHandler: + """Handler to resolve file refs.""" + + def __call__(self, uri): + filepath = self._uri_to_path(uri) + with open(filepath) as fh: + return yaml.load(fh, ExtendedSafeLoader) + + @staticmethod + def _uri_to_path(uri): + parsed = urllib.parse.urlparse(uri) + host = "{0}{0}{mnt}{0}".format(os.path.sep, mnt=parsed.netloc) + return os.path.abspath( + os.path.join(host, urllib.request.url2pathname(parsed.path)) + ) + + +class URLHandler: + """Handler to resolve url refs.""" + + def __call__(self, uri): + response = requests.get(uri) + response.raise_for_status() + + data = io.StringIO(response.text) + with contextlib.closing(data) as fh: + return yaml.load(fh, ExtendedSafeLoader) + + +default_handlers = { + 'http': URLHandler(), + 'https': URLHandler(), + 'file': FileHandler(), +} + + +def resolve_refs(spec, store=None, handlers=None): + """ + Resolve JSON references like {"$ref": } in a spec. + Optionally takes a store, which is a mapping from reference URLs to a + dereferenced objects. Prepopulating the store can avoid network calls. + """ + spec = deepcopy(spec) + store = store or {} + handlers = handlers or default_handlers + resolver = RefResolver('', spec, store, handlers=handlers) + + def _do_resolve(node): + if isinstance(node, Mapping) and '$ref' in node: + path = node['$ref'][2:].split("/") + try: + # resolve known references + node.update(deep_get(spec, path)) + del node['$ref'] + return node + except KeyError: + # resolve external references + with resolver.resolving(node['$ref']) as resolved: + return resolved + elif isinstance(node, Mapping): + for k, v in node.items(): + node[k] = _do_resolve(v) + elif isinstance(node, (list, tuple)): + for i, _ in enumerate(node): + node[i] = _do_resolve(node[i]) + return node + + res = _do_resolve(spec) + return res + + +def allow_nullable(validation_fn: t.Callable) -> t.Callable: + """Extend an existing validation function, so it allows nullable values to be null.""" + + def nullable_validation_fn(validator, to_validate, instance, schema): + if instance is None and (schema.get('x-nullable') is True or schema.get('nullable')): + return + + yield from validation_fn(validator, to_validate, instance, schema) + + return nullable_validation_fn + + +def validate_required(validator, required, instance, schema): + if not validator.is_type(instance, "object"): + return + + for prop in required: + if prop not in instance: + properties = schema.get('properties') + if properties is not None: + subschema = properties.get(prop) + if subschema is not None: + if 'readOnly' in validator.VALIDATORS and subschema.get('readOnly'): + continue + if 'writeOnly' in validator.VALIDATORS and subschema.get('writeOnly'): + continue + if 'x-writeOnly' in validator.VALIDATORS and subschema.get('x-writeOnly') is True: + continue + yield ValidationError("%r is a required property" % prop) + + +def validate_readOnly(validator, ro, instance, schema): + yield ValidationError("Property is read-only") + + +def validate_writeOnly(validator, wo, instance, schema): + yield ValidationError("Property is write-only") + + +NullableTypeValidator = allow_nullable(Draft4Validator.VALIDATORS['type']) +NullableEnumValidator = allow_nullable(Draft4Validator.VALIDATORS['enum']) + +Draft4RequestValidator = extend(Draft4Validator, { + 'type': NullableTypeValidator, + 'enum': NullableEnumValidator, + 'required': validate_required, + 'readOnly': validate_readOnly}) + +Draft4ResponseValidator = extend(Draft4Validator, { + 'type': NullableTypeValidator, + 'enum': NullableEnumValidator, + 'required': validate_required, + 'writeOnly': validate_writeOnly, + 'x-writeOnly': validate_writeOnly}) diff --git a/firetail/jsonifier.py b/firetail/jsonifier.py index bff6516..9e08ab4 100644 --- a/firetail/jsonifier.py +++ b/firetail/jsonifier.py @@ -1,71 +1,70 @@ -""" -This module centralizes all functionality related to json encoding and decoding in Firetail. -""" - -import datetime -import json -import uuid - - -class JSONEncoder(json.JSONEncoder): - """The default Firetail JSON encoder. Handles extra types compared to the - built-in :class:`json.JSONEncoder`. - - - :class:`datetime.datetime` and :class:`datetime.date` are - serialized to :rfc:`822` strings. This is the same as the HTTP - date format. - - :class:`uuid.UUID` is serialized to a string. - """ - - def default(self, o): - if isinstance(o, datetime.datetime): - if o.tzinfo: - # eg: '2015-09-25T23:14:42.588601+00:00' - return o.isoformat('T') - else: - # No timezone present - assume UTC. - # eg: '2015-09-25T23:14:42.588601Z' - return o.isoformat('T') + 'Z' - - if isinstance(o, datetime.date): - return o.isoformat() - - if isinstance(o, uuid.UUID): - return str(o) - - return json.JSONEncoder.default(self, o) - - -class Jsonifier: - """ - Central point to serialize and deserialize to/from JSon in Firetail. - """ - - def __init__(self, json_=json, **kwargs): - """ - :param json_: json library to use. Must have loads() and dumps() method # NOQA - :param kwargs: default arguments to pass to json.dumps() - """ - self.json = json_ - self.dumps_args = kwargs - - def dumps(self, data, **kwargs): - """ Central point where JSON serialization happens inside - Firetail. - """ - for k, v in self.dumps_args.items(): - kwargs.setdefault(k, v) - return self.json.dumps(data, **kwargs) + '\n' - - def loads(self, data): - """ Central point where JSON deserialization happens inside - Firetail. - """ - if isinstance(data, bytes): - data = data.decode() - - try: - return self.json.loads(data) - except Exception: - if isinstance(data, str): - return data +""" +This module centralizes all functionality related to json encoding and decoding in Firetail. +""" + +import datetime +import json +import uuid + + +class JSONEncoder(json.JSONEncoder): + """The default Firetail JSON encoder. Handles extra types compared to the + built-in :class:`json.JSONEncoder`. + + - :class:`datetime.datetime` and :class:`datetime.date` are + serialized to :rfc:`822` strings. This is the same as the HTTP + date format. + - :class:`uuid.UUID` is serialized to a string. + """ + + def default(self, o): + if isinstance(o, datetime.datetime): + if o.tzinfo: + # eg: '2015-09-25T23:14:42.588601+00:00' + return o.isoformat('T') + else: + # No timezone present - assume UTC. + # eg: '2015-09-25T23:14:42.588601Z' + return o.isoformat('T') + 'Z' + + if isinstance(o, datetime.date): + return o.isoformat() + + if isinstance(o, uuid.UUID): + return str(o) + + return json.JSONEncoder.default(self, o) + + +class Jsonifier: + """ + Central point to serialize and deserialize to/from JSon in Firetail. + """ + def __init__(self, json_=json, **kwargs): + """ + :param json_: json library to use. Must have loads() and dumps() method # NOQA + :param kwargs: default arguments to pass to json.dumps() + """ + self.json = json_ + self.dumps_args = kwargs + + def dumps(self, data, **kwargs): + """ Central point where JSON serialization happens inside + Firetail. + """ + for k, v in self.dumps_args.items(): + kwargs.setdefault(k, v) + return self.json.dumps(data, **kwargs) + '\n' + + def loads(self, data): + """ Central point where JSON deserialization happens inside + Firetail. + """ + if isinstance(data, bytes): + data = data.decode() + + try: + return self.json.loads(data) + except Exception: + if isinstance(data, str): + return data diff --git a/firetail/lifecycle.py b/firetail/lifecycle.py index 21b5e9d..47dba4f 100644 --- a/firetail/lifecycle.py +++ b/firetail/lifecycle.py @@ -1,56 +1,54 @@ -""" -This module defines interfaces for requests and responses used in Firetail for authentication, -validation, serialization, etc. -""" - - -class FiretailRequest: - """Firetail interface for a request.""" - - def __init__(self, - url, - method, - path_params=None, - query=None, - headers=None, - form=None, - body=None, - json_getter=None, - files=None, - context=None, - cookies=None): - self.url = url - self.method = method - self.path_params = path_params or {} - self.query = query or {} - self.headers = headers or {} - self.form = form or {} - self.body = body - self.json_getter = json_getter - self.files = files - self.context = context if context is not None else {} - self.cookies = cookies or {} - - @property - def json(self): - if not hasattr(self, '_json'): - self._json = self.json_getter() - return self._json - - -class FiretailResponse: - """Firetail interface for a response.""" - - def __init__(self, - status_code=200, - mimetype=None, - content_type=None, - body=None, - headers=None, - is_streamed=False): - self.status_code = status_code - self.mimetype = mimetype - self.content_type = content_type - self.body = body - self.headers = headers or {} - self.is_streamed = is_streamed +""" +This module defines interfaces for requests and responses used in Firetail for authentication, +validation, serialization, etc. +""" + + +class FiretailRequest: + """Firetail interface for a request.""" + def __init__(self, + url, + method, + path_params=None, + query=None, + headers=None, + form=None, + body=None, + json_getter=None, + files=None, + context=None, + cookies=None): + self.url = url + self.method = method + self.path_params = path_params or {} + self.query = query or {} + self.headers = headers or {} + self.form = form or {} + self.body = body + self.json_getter = json_getter + self.files = files + self.context = context if context is not None else {} + self.cookies = cookies or {} + + @property + def json(self): + if not hasattr(self, '_json'): + self._json = self.json_getter() + return self._json + + +class FiretailResponse: + """Firetail interface for a response.""" + def __init__(self, + status_code=200, + mimetype=None, + content_type=None, + body=None, + headers=None, + is_streamed=False): + self.status_code = status_code + self.mimetype = mimetype + self.content_type = content_type + self.body = body + self.headers = headers or {} + self.is_streamed = is_streamed diff --git a/firetail/mock.py b/firetail/mock.py index 90ea779..d8dca6c 100644 --- a/firetail/mock.py +++ b/firetail/mock.py @@ -1,51 +1,51 @@ -""" -This module contains a mock resolver that returns mock functions for operations it cannot resolve. -""" - -import functools -import logging - -from firetail.resolver import Resolution, Resolver, ResolverError - -logger = logging.getLogger(__name__) - - -class MockResolver(Resolver): - - def __init__(self, mock_all): - super().__init__() - self.mock_all = mock_all - self._operation_id_counter = 1 - - def resolve(self, operation): - """ - Mock operation resolver - - :type operation: firetail.operations.AbstractOperation - """ - operation_id = self.resolve_operation_id(operation) - if not operation_id: - # just generate an unique operation ID - operation_id = f'mock-{self._operation_id_counter}' - self._operation_id_counter += 1 - - mock_func = functools.partial(self.mock_operation, operation=operation) - if self.mock_all: - func = mock_func - else: - try: - func = self.resolve_function_from_operation_id(operation_id) - msg = "... Successfully resolved operationId '{}'! Mock is *not* used for this operation.".format( - operation_id) - logger.debug(msg) - except ResolverError as resolution_error: - logger.debug('... {}! Mock function is used for this operation.'.format( - resolution_error.reason.capitalize())) - func = mock_func - return Resolution(func, operation_id) - - def mock_operation(self, operation, *args, **kwargs): - resp, code = operation.example_response() - if resp is not None: - return resp, code - return 'No example response was defined.', code +""" +This module contains a mock resolver that returns mock functions for operations it cannot resolve. +""" + +import functools +import logging + +from firetail.resolver import Resolution, Resolver, ResolverError + +logger = logging.getLogger(__name__) + + +class MockResolver(Resolver): + + def __init__(self, mock_all): + super().__init__() + self.mock_all = mock_all + self._operation_id_counter = 1 + + def resolve(self, operation): + """ + Mock operation resolver + + :type operation: firetail.operations.AbstractOperation + """ + operation_id = self.resolve_operation_id(operation) + if not operation_id: + # just generate an unique operation ID + operation_id = f'mock-{self._operation_id_counter}' + self._operation_id_counter += 1 + + mock_func = functools.partial(self.mock_operation, operation=operation) + if self.mock_all: + func = mock_func + else: + try: + func = self.resolve_function_from_operation_id(operation_id) + msg = "... Successfully resolved operationId '{}'! Mock is *not* used for this operation.".format( + operation_id) + logger.debug(msg) + except ResolverError as resolution_error: + logger.debug('... {}! Mock function is used for this operation.'.format( + resolution_error.reason.capitalize())) + func = mock_func + return Resolution(func, operation_id) + + def mock_operation(self, operation, *args, **kwargs): + resp, code = operation.example_response() + if resp is not None: + return resp, code + return 'No example response was defined.', code diff --git a/firetail/operations/__init__.py b/firetail/operations/__init__.py index d51a99e..941efb8 100644 --- a/firetail/operations/__init__.py +++ b/firetail/operations/__init__.py @@ -1,16 +1,16 @@ -""" -This module defines Firetail Operation classes. A Firetail Operation implements an OpenAPI -operation, which describes a single API operation on a path. It wraps the view function linked to -the operation with decorators to handle security, validation, serialization etc. based on the -OpenAPI specification, and exposes the result to be registered as a route on the application. - -""" - -from .abstract import AbstractOperation # noqa -from .openapi import OpenAPIOperation # noqa -from .secure import SecureOperation # noqa -from .swagger2 import Swagger2Operation # noqa - - -def make_operation(spec, *args, **kwargs): - return spec.operation_cls.from_spec(spec, *args, **kwargs) +""" +This module defines Firetail Operation classes. A Firetail Operation implements an OpenAPI +operation, which describes a single API operation on a path. It wraps the view function linked to +the operation with decorators to handle security, validation, serialization etc. based on the +OpenAPI specification, and exposes the result to be registered as a route on the application. + +""" + +from .abstract import AbstractOperation # noqa +from .openapi import OpenAPIOperation # noqa +from .secure import SecureOperation # noqa +from .swagger2 import Swagger2Operation # noqa + + +def make_operation(spec, *args, **kwargs): + return spec.operation_cls.from_spec(spec, *args, **kwargs) diff --git a/firetail/operations/abstract.py b/firetail/operations/abstract.py index 73f9273..975bacb 100644 --- a/firetail/operations/abstract.py +++ b/firetail/operations/abstract.py @@ -1,458 +1,458 @@ -""" -This module defines an AbstractOperation class which implements an abstract Operation interface -and functionality shared between Swagger 2 and OpenAPI 3 specifications. -""" - -import abc -import logging - -from firetail.operations.secure import SecureOperation - -from ..decorators.metrics import UWSGIMetricsCollector -from ..decorators.parameter import parameter_to_arg -from ..decorators.produces import BaseSerializer, Produces -from ..decorators.response import ResponseValidator -from ..decorators.validation import ParameterValidator, RequestBodyValidator -from ..utils import all_json, is_nullable - -logger = logging.getLogger('firetail.operations.abstract') - -DEFAULT_MIMETYPE = 'application/json' - -VALIDATOR_MAP = { - 'parameter': ParameterValidator, - 'body': RequestBodyValidator, - 'response': ResponseValidator, -} - - -class AbstractOperation(SecureOperation, metaclass=abc.ABCMeta): - - """ - An API routes requests to an Operation by a (path, method) pair. - The operation uses a resolver to resolve its handler function. - We use the provided spec to do a bunch of heavy lifting before - (and after) we call security_schemes handler. - The registered handler function ends up looking something like:: - - @secure_endpoint - @validate_inputs - @deserialize_function_inputs - @serialize_function_outputs - @validate_outputs - def user_provided_handler_function(important, stuff): - if important: - serious_business(stuff) - """ - def __init__(self, api, method, path, operation, resolver, - app_security=None, security_schemes=None, - validate_responses=False, strict_validation=False, - randomize_endpoint=None, validator_map=None, - pythonic_params=False, uri_parser_class=None, - pass_context_arg_name=None): - """ - :param api: api that this operation is attached to - :type api: apis.AbstractAPI - :param method: HTTP method - :type method: str - :param path: - :type path: str - :param operation: swagger operation object - :type operation: dict - :param resolver: Callable that maps operationID to a function - :param app_produces: list of content types the application can return by default - :param app_security: list of security rules the application uses by default - :type app_security: list - :param security_schemes: `Security Definitions Object - `_ - :type security_schemes: dict - :param validate_responses: True enables validation. Validation errors generate HTTP 500 responses. - :type validate_responses: bool - :param strict_validation: True enables validation on invalid request parameters - :type strict_validation: bool - :param randomize_endpoint: number of random characters to append to operation name - :type randomize_endpoint: integer - :param validator_map: Custom validators for the types "parameter", "body" and "response". - :type validator_map: dict - :param pythonic_params: When True CamelCase parameters are converted to snake_case and an underscore is appended - to any shadowed built-ins - :type pythonic_params: bool - :param uri_parser_class: class to use for uri parsing - :type uri_parser_class: AbstractURIParser - :param pass_context_arg_name: If not None will try to inject the request context to the function using this - name. - :type pass_context_arg_name: str|None - """ - self._api = api - self._method = method - self._path = path - self._operation = operation - self._resolver = resolver - self._security = app_security - self._security_schemes = security_schemes - self._validate_responses = validate_responses - self._strict_validation = strict_validation - self._pythonic_params = pythonic_params - self._uri_parser_class = uri_parser_class - self._pass_context_arg_name = pass_context_arg_name - self._randomize_endpoint = randomize_endpoint - - self._operation_id = self._operation.get("operationId") - self._resolution = resolver.resolve(self) - self._operation_id = self._resolution.operation_id - - self._responses = self._operation.get("responses", {}) - - self._validator_map = dict(VALIDATOR_MAP) - self._validator_map.update(validator_map or {}) - - @property - def method(self): - """ - The HTTP method for this operation (ex. GET, POST) - """ - return self._method - - @property - def path(self): - """ - The path of the operation, relative to the API base path - """ - return self._path - - @property - def responses(self): - """ - Returns the responses for this operation - """ - return self._responses - - @property - def validator_map(self): - """ - Validators to use for parameter, body, and response validation - """ - return self._validator_map - - @property - def operation_id(self): - """ - The operation id used to identify the operation internally to the app - """ - return self._operation_id - - @property - def randomize_endpoint(self): - """ - number of random digits to generate and append to the operation_id. - """ - return self._randomize_endpoint - - @property - def router_controller(self): - """ - The router controller to use (python module where handler functions live) - """ - return self._router_controller - - @property - def strict_validation(self): - """ - If True, validate all requests against the spec - """ - return self._strict_validation - - @property - def pythonic_params(self): - """ - If True, convert CamelCase into pythonic_variable_names - """ - return self._pythonic_params - - @property - def validate_responses(self): - """ - If True, check the response against the response schema, and return an - error if the response does not validate. - """ - return self._validate_responses - - @staticmethod - def _get_file_arguments(files, arguments, has_kwargs=False): - return {k: v for k, v in files.items() if k in arguments or has_kwargs} - - @abc.abstractmethod - def _get_val_from_param(self, value, query_defn): - """ - Convert input parameters into the correct type - """ - - def _query_args_helper(self, query_defns, query_arguments, - function_arguments, has_kwargs, sanitize): - res = {} - for key, value in query_arguments.items(): - sanitized_key = sanitize(key) - if not has_kwargs and sanitized_key not in function_arguments: - logger.debug("Query Parameter '%s' (sanitized: '%s') not in function arguments", - key, sanitized_key) - else: - logger.debug("Query Parameter '%s' (sanitized: '%s') in function arguments", - key, sanitized_key) - try: - query_defn = query_defns[key] - except KeyError: # pragma: no cover - logger.error("Function argument '%s' (non-sanitized: %s) not defined in specification", - sanitized_key, key) - else: - logger.debug('%s is a %s', key, query_defn) - res.update({sanitized_key: self._get_val_from_param(value, query_defn)}) - return res - - @abc.abstractmethod - def _get_query_arguments(self, query, arguments, has_kwargs, sanitize): - """ - extract handler function arguments from the query parameters - """ - - @abc.abstractmethod - def _get_body_argument(self, body, arguments, has_kwargs, sanitize): - """ - extract handler function arguments from the request body - """ - - def _get_path_arguments(self, path_params, sanitize): - """ - extract handler function arguments from path parameters - """ - kwargs = {} - path_defns = {p["name"]: p for p in self.parameters if p["in"] == "path"} - for key, value in path_params.items(): - sanitized_key = sanitize(key) - if key in path_defns: - kwargs[sanitized_key] = self._get_val_from_param(value, path_defns[key]) - else: # Assume path params mechanism used for injection - kwargs[sanitized_key] = value - return kwargs - - @property - @abc.abstractmethod - def parameters(self): - """ - Returns the parameters for this operation - """ - - @property - @abc.abstractmethod - def produces(self): - """ - Content-Types that the operation produces - """ - - @property - @abc.abstractmethod - def consumes(self): - """ - Content-Types that the operation consumes - """ - - @property - @abc.abstractmethod - def body_schema(self): - """ - The body schema definition for this operation. - """ - - @property - @abc.abstractmethod - def body_definition(self): - """ - The body definition for this operation. - :rtype: dict - """ - - def get_arguments(self, path_params, query_params, body, files, arguments, - has_kwargs, sanitize): - """ - get arguments for handler function - """ - ret = {} - ret.update(self._get_path_arguments(path_params, sanitize)) - ret.update(self._get_query_arguments(query_params, arguments, - has_kwargs, sanitize)) - - if self.method.upper() in ["PATCH", "POST", "PUT"]: - ret.update(self._get_body_argument(body, arguments, - has_kwargs, sanitize)) - ret.update(self._get_file_arguments(files, arguments, has_kwargs)) - return ret - - def response_definition(self, status_code=None, - content_type=None): - """ - response definition for this endpoint - """ - content_type = content_type or self.get_mimetype() - response_definition = self.responses.get( - str(status_code), - self.responses.get("default", {}) - ) - return response_definition - - @abc.abstractmethod - def response_schema(self, status_code=None, content_type=None): - """ - response schema for this endpoint - """ - - @abc.abstractmethod - def example_response(self, status_code=None, content_type=None): - """ - Returns an example from the spec - """ - - @abc.abstractmethod - def get_path_parameter_types(self): - """ - Returns the types for parameters in the path - """ - - @abc.abstractmethod - def with_definitions(self, schema): - """ - Returns the given schema, but with the definitions from the spec - attached. This allows any remaining references to be resolved by a - validator (for example). - """ - - def get_mimetype(self): - """ - If the endpoint has no 'produces' then the default is - 'application/json'. - - :rtype str - """ - if all_json(self.produces): - try: - return self.produces[0] - except IndexError: - return DEFAULT_MIMETYPE - elif len(self.produces) == 1: - return self.produces[0] - else: - return DEFAULT_MIMETYPE - - @property - def _uri_parsing_decorator(self): - """ - Returns a decorator that parses request data and handles things like - array types, and duplicate parameter definitions. - """ - return self._uri_parser_class(self.parameters, self.body_definition) - - @property - def function(self): - """ - Operation function with decorators - - :rtype: types.FunctionType - """ - function = parameter_to_arg( - self, self._resolution.function, self.pythonic_params, - self._pass_context_arg_name - ) - - if self.validate_responses: - logger.debug('... Response validation enabled.') - response_decorator = self.__response_validation_decorator - logger.debug('... Adding response decorator (%r)', response_decorator) - function = response_decorator(function) - - produces_decorator = self.__content_type_decorator - logger.debug('... Adding produces decorator (%r)', produces_decorator) - function = produces_decorator(function) - - for validation_decorator in self.__validation_decorators: - function = validation_decorator(function) - - uri_parsing_decorator = self._uri_parsing_decorator - function = uri_parsing_decorator(function) - - # NOTE: the security decorator should be applied last to check auth before anything else :-) - security_decorator = self.security_decorator - logger.debug('... Adding security decorator (%r)', security_decorator) - function = security_decorator(function) - - function = self._request_response_decorator(function) - - if UWSGIMetricsCollector.is_available(): # pragma: no cover - decorator = UWSGIMetricsCollector(self.path, self.method) - function = decorator(function) - - return function - - @property - def __content_type_decorator(self): - """ - Get produces decorator. - - If the operation mimetype format is json then the function return value is jsonified - - From Swagger Specification: - - **Produces** - - A list of MIME types the operation can produce. This overrides the produces definition at the Swagger Object. - An empty value MAY be used to clear the global definition. - - :rtype: types.FunctionType - """ - - logger.debug('... Produces: %s', self.produces, extra=vars(self)) - - mimetype = self.get_mimetype() - if all_json(self.produces): # endpoint will return json - logger.debug('... Produces json', extra=vars(self)) - # TODO: Refactor this. - return lambda f: f - - elif len(self.produces) == 1: - logger.debug('... Produces %s', mimetype, extra=vars(self)) - decorator = Produces(mimetype) - return decorator - - else: - return BaseSerializer() - - @property - def __validation_decorators(self): - """ - :rtype: types.FunctionType - """ - ParameterValidator = self.validator_map['parameter'] - RequestBodyValidator = self.validator_map['body'] - if self.parameters: - yield ParameterValidator(self.parameters, - self.api, - strict_validation=self.strict_validation) - if self.body_schema: - yield RequestBodyValidator(self.body_schema, self.consumes, self.api, - is_nullable(self.body_definition), - strict_validation=self.strict_validation) - - @property - def __response_validation_decorator(self): - """ - Get a decorator for validating the generated Response. - :rtype: types.FunctionType - """ - ResponseValidator = self.validator_map['response'] - return ResponseValidator(self, self.get_mimetype()) - - def json_loads(self, data): - """ - A wrapper for calling the API specific JSON loader. - - :param data: The JSON data in textual form. - :type data: bytes - """ - return self.api.json_loads(data) +""" +This module defines an AbstractOperation class which implements an abstract Operation interface +and functionality shared between Swagger 2 and OpenAPI 3 specifications. +""" + +import abc +import logging + +from firetail.operations.secure import SecureOperation + +from ..decorators.metrics import UWSGIMetricsCollector +from ..decorators.parameter import parameter_to_arg +from ..decorators.produces import BaseSerializer, Produces +from ..decorators.response import ResponseValidator +from ..decorators.validation import ParameterValidator, RequestBodyValidator +from ..utils import all_json, is_nullable + +logger = logging.getLogger('firetail.operations.abstract') + +DEFAULT_MIMETYPE = 'application/json' + +VALIDATOR_MAP = { + 'parameter': ParameterValidator, + 'body': RequestBodyValidator, + 'response': ResponseValidator, +} + + +class AbstractOperation(SecureOperation, metaclass=abc.ABCMeta): + + """ + An API routes requests to an Operation by a (path, method) pair. + The operation uses a resolver to resolve its handler function. + We use the provided spec to do a bunch of heavy lifting before + (and after) we call security_schemes handler. + The registered handler function ends up looking something like:: + + @secure_endpoint + @validate_inputs + @deserialize_function_inputs + @serialize_function_outputs + @validate_outputs + def user_provided_handler_function(important, stuff): + if important: + serious_business(stuff) + """ + def __init__(self, api, method, path, operation, resolver, + app_security=None, security_schemes=None, + validate_responses=False, strict_validation=False, + randomize_endpoint=None, validator_map=None, + pythonic_params=False, uri_parser_class=None, + pass_context_arg_name=None): + """ + :param api: api that this operation is attached to + :type api: apis.AbstractAPI + :param method: HTTP method + :type method: str + :param path: + :type path: str + :param operation: swagger operation object + :type operation: dict + :param resolver: Callable that maps operationID to a function + :param app_produces: list of content types the application can return by default + :param app_security: list of security rules the application uses by default + :type app_security: list + :param security_schemes: `Security Definitions Object + `_ + :type security_schemes: dict + :param validate_responses: True enables validation. Validation errors generate HTTP 500 responses. + :type validate_responses: bool + :param strict_validation: True enables validation on invalid request parameters + :type strict_validation: bool + :param randomize_endpoint: number of random characters to append to operation name + :type randomize_endpoint: integer + :param validator_map: Custom validators for the types "parameter", "body" and "response". + :type validator_map: dict + :param pythonic_params: When True CamelCase parameters are converted to snake_case and an underscore is appended + to any shadowed built-ins + :type pythonic_params: bool + :param uri_parser_class: class to use for uri parsing + :type uri_parser_class: AbstractURIParser + :param pass_context_arg_name: If not None will try to inject the request context to the function using this + name. + :type pass_context_arg_name: str|None + """ + self._api = api + self._method = method + self._path = path + self._operation = operation + self._resolver = resolver + self._security = app_security + self._security_schemes = security_schemes + self._validate_responses = validate_responses + self._strict_validation = strict_validation + self._pythonic_params = pythonic_params + self._uri_parser_class = uri_parser_class + self._pass_context_arg_name = pass_context_arg_name + self._randomize_endpoint = randomize_endpoint + + self._operation_id = self._operation.get("operationId") + self._resolution = resolver.resolve(self) + self._operation_id = self._resolution.operation_id + + self._responses = self._operation.get("responses", {}) + + self._validator_map = dict(VALIDATOR_MAP) + self._validator_map.update(validator_map or {}) + + @property + def method(self): + """ + The HTTP method for this operation (ex. GET, POST) + """ + return self._method + + @property + def path(self): + """ + The path of the operation, relative to the API base path + """ + return self._path + + @property + def responses(self): + """ + Returns the responses for this operation + """ + return self._responses + + @property + def validator_map(self): + """ + Validators to use for parameter, body, and response validation + """ + return self._validator_map + + @property + def operation_id(self): + """ + The operation id used to identify the operation internally to the app + """ + return self._operation_id + + @property + def randomize_endpoint(self): + """ + number of random digits to generate and append to the operation_id. + """ + return self._randomize_endpoint + + @property + def router_controller(self): + """ + The router controller to use (python module where handler functions live) + """ + return self._router_controller + + @property + def strict_validation(self): + """ + If True, validate all requests against the spec + """ + return self._strict_validation + + @property + def pythonic_params(self): + """ + If True, convert CamelCase into pythonic_variable_names + """ + return self._pythonic_params + + @property + def validate_responses(self): + """ + If True, check the response against the response schema, and return an + error if the response does not validate. + """ + return self._validate_responses + + @staticmethod + def _get_file_arguments(files, arguments, has_kwargs=False): + return {k: v for k, v in files.items() if k in arguments or has_kwargs} + + @abc.abstractmethod + def _get_val_from_param(self, value, query_defn): + """ + Convert input parameters into the correct type + """ + + def _query_args_helper(self, query_defns, query_arguments, + function_arguments, has_kwargs, sanitize): + res = {} + for key, value in query_arguments.items(): + sanitized_key = sanitize(key) + if not has_kwargs and sanitized_key not in function_arguments: + logger.debug("Query Parameter '%s' (sanitized: '%s') not in function arguments", + key, sanitized_key) + else: + logger.debug("Query Parameter '%s' (sanitized: '%s') in function arguments", + key, sanitized_key) + try: + query_defn = query_defns[key] + except KeyError: # pragma: no cover + logger.error("Function argument '%s' (non-sanitized: %s) not defined in specification", + sanitized_key, key) + else: + logger.debug('%s is a %s', key, query_defn) + res.update({sanitized_key: self._get_val_from_param(value, query_defn)}) + return res + + @abc.abstractmethod + def _get_query_arguments(self, query, arguments, has_kwargs, sanitize): + """ + extract handler function arguments from the query parameters + """ + + @abc.abstractmethod + def _get_body_argument(self, body, arguments, has_kwargs, sanitize): + """ + extract handler function arguments from the request body + """ + + def _get_path_arguments(self, path_params, sanitize): + """ + extract handler function arguments from path parameters + """ + kwargs = {} + path_defns = {p["name"]: p for p in self.parameters if p["in"] == "path"} + for key, value in path_params.items(): + sanitized_key = sanitize(key) + if key in path_defns: + kwargs[sanitized_key] = self._get_val_from_param(value, path_defns[key]) + else: # Assume path params mechanism used for injection + kwargs[sanitized_key] = value + return kwargs + + @property + @abc.abstractmethod + def parameters(self): + """ + Returns the parameters for this operation + """ + + @property + @abc.abstractmethod + def produces(self): + """ + Content-Types that the operation produces + """ + + @property + @abc.abstractmethod + def consumes(self): + """ + Content-Types that the operation consumes + """ + + @property + @abc.abstractmethod + def body_schema(self): + """ + The body schema definition for this operation. + """ + + @property + @abc.abstractmethod + def body_definition(self): + """ + The body definition for this operation. + :rtype: dict + """ + + def get_arguments(self, path_params, query_params, body, files, arguments, + has_kwargs, sanitize): + """ + get arguments for handler function + """ + ret = {} + ret.update(self._get_path_arguments(path_params, sanitize)) + ret.update(self._get_query_arguments(query_params, arguments, + has_kwargs, sanitize)) + + if self.method.upper() in ["PATCH", "POST", "PUT"]: + ret.update(self._get_body_argument(body, arguments, + has_kwargs, sanitize)) + ret.update(self._get_file_arguments(files, arguments, has_kwargs)) + return ret + + def response_definition(self, status_code=None, + content_type=None): + """ + response definition for this endpoint + """ + content_type = content_type or self.get_mimetype() + response_definition = self.responses.get( + str(status_code), + self.responses.get("default", {}) + ) + return response_definition + + @abc.abstractmethod + def response_schema(self, status_code=None, content_type=None): + """ + response schema for this endpoint + """ + + @abc.abstractmethod + def example_response(self, status_code=None, content_type=None): + """ + Returns an example from the spec + """ + + @abc.abstractmethod + def get_path_parameter_types(self): + """ + Returns the types for parameters in the path + """ + + @abc.abstractmethod + def with_definitions(self, schema): + """ + Returns the given schema, but with the definitions from the spec + attached. This allows any remaining references to be resolved by a + validator (for example). + """ + + def get_mimetype(self): + """ + If the endpoint has no 'produces' then the default is + 'application/json'. + + :rtype str + """ + if all_json(self.produces): + try: + return self.produces[0] + except IndexError: + return DEFAULT_MIMETYPE + elif len(self.produces) == 1: + return self.produces[0] + else: + return DEFAULT_MIMETYPE + + @property + def _uri_parsing_decorator(self): + """ + Returns a decorator that parses request data and handles things like + array types, and duplicate parameter definitions. + """ + return self._uri_parser_class(self.parameters, self.body_definition) + + @property + def function(self): + """ + Operation function with decorators + + :rtype: types.FunctionType + """ + function = parameter_to_arg( + self, self._resolution.function, self.pythonic_params, + self._pass_context_arg_name + ) + + if self.validate_responses: + logger.debug('... Response validation enabled.') + response_decorator = self.__response_validation_decorator + logger.debug('... Adding response decorator (%r)', response_decorator) + function = response_decorator(function) + + produces_decorator = self.__content_type_decorator + logger.debug('... Adding produces decorator (%r)', produces_decorator) + function = produces_decorator(function) + + for validation_decorator in self.__validation_decorators: + function = validation_decorator(function) + + uri_parsing_decorator = self._uri_parsing_decorator + function = uri_parsing_decorator(function) + + # NOTE: the security decorator should be applied last to check auth before anything else :-) + security_decorator = self.security_decorator + logger.debug('... Adding security decorator (%r)', security_decorator) + function = security_decorator(function) + + function = self._request_response_decorator(function) + + if UWSGIMetricsCollector.is_available(): # pragma: no cover + decorator = UWSGIMetricsCollector(self.path, self.method) + function = decorator(function) + + return function + + @property + def __content_type_decorator(self): + """ + Get produces decorator. + + If the operation mimetype format is json then the function return value is jsonified + + From Swagger Specification: + + **Produces** + + A list of MIME types the operation can produce. This overrides the produces definition at the Swagger Object. + An empty value MAY be used to clear the global definition. + + :rtype: types.FunctionType + """ + + logger.debug('... Produces: %s', self.produces, extra=vars(self)) + + mimetype = self.get_mimetype() + if all_json(self.produces): # endpoint will return json + logger.debug('... Produces json', extra=vars(self)) + # TODO: Refactor this. + return lambda f: f + + elif len(self.produces) == 1: + logger.debug('... Produces %s', mimetype, extra=vars(self)) + decorator = Produces(mimetype) + return decorator + + else: + return BaseSerializer() + + @property + def __validation_decorators(self): + """ + :rtype: types.FunctionType + """ + ParameterValidator = self.validator_map['parameter'] + RequestBodyValidator = self.validator_map['body'] + if self.parameters: + yield ParameterValidator(self.parameters, + self.api, + strict_validation=self.strict_validation) + if self.body_schema: + yield RequestBodyValidator(self.body_schema, self.consumes, self.api, + is_nullable(self.body_definition), + strict_validation=self.strict_validation) + + @property + def __response_validation_decorator(self): + """ + Get a decorator for validating the generated Response. + :rtype: types.FunctionType + """ + ResponseValidator = self.validator_map['response'] + return ResponseValidator(self, self.get_mimetype()) + + def json_loads(self, data): + """ + A wrapper for calling the API specific JSON loader. + + :param data: The JSON data in textual form. + :type data: bytes + """ + return self.api.json_loads(data) diff --git a/firetail/operations/compat.py b/firetail/operations/compat.py index 753c84e..8596b66 100644 --- a/firetail/operations/compat.py +++ b/firetail/operations/compat.py @@ -1,5 +1,5 @@ -""" -This is a dummy module for backwards compatibility with < v2.0. -""" -from .secure import * # noqa -from .swagger2 import * # noqa +""" +This is a dummy module for backwards compatibility with < v2.0. +""" +from .secure import * # noqa +from .swagger2 import * # noqa diff --git a/firetail/operations/openapi.py b/firetail/operations/openapi.py index dfddcef..e2bddf7 100644 --- a/firetail/operations/openapi.py +++ b/firetail/operations/openapi.py @@ -1,413 +1,407 @@ -""" -This module defines an OpenAPIOperation class, a Firetail operation specific for OpenAPI 3 specs. -""" - -import logging -import warnings -from copy import copy, deepcopy - -from firetail.operations.abstract import AbstractOperation - -from ..decorators.uri_parsing import OpenAPIURIParser -from ..utils import deep_get, deep_merge, is_null, is_nullable, make_type - -logger = logging.getLogger("firetail.operations.openapi3") - - -class OpenAPIOperation(AbstractOperation): - - """ - A single API operation on a path. - """ - - def __init__(self, api, method, path, operation, resolver, path_parameters=None, - app_security=None, components=None, validate_responses=False, - strict_validation=False, randomize_endpoint=None, validator_map=None, - pythonic_params=False, uri_parser_class=None, pass_context_arg_name=None): - """ - This class uses the OperationID identify the module and function that will handle the operation - - From Swagger Specification: - - **OperationID** - - A friendly name for the operation. The id MUST be unique among all operations described in the API. - Tools and libraries MAY use the operation id to uniquely identify an operation. - - :param method: HTTP method - :type method: str - :param path: - :type path: str - :param operation: swagger operation object - :type operation: dict - :param resolver: Callable that maps operationID to a function - :param path_parameters: Parameters defined in the path level - :type path_parameters: list - :param app_security: list of security rules the application uses by default - :type app_security: list - :param components: `Components Object - `_ - :type components: dict - :param validate_responses: True enables validation. Validation errors generate HTTP 500 responses. - :type validate_responses: bool - :param strict_validation: True enables validation on invalid request parameters - :type strict_validation: bool - :param randomize_endpoint: number of random characters to append to operation name - :type randomize_endpoint: integer - :param validator_map: Custom validators for the types "parameter", "body" and "response". - :type validator_map: dict - :param pythonic_params: When True CamelCase parameters are converted to snake_case and an underscore is appended - to any shadowed built-ins - :type pythonic_params: bool - :param uri_parser_class: class to use for uri parsing - :type uri_parser_class: AbstractURIParser - :param pass_context_arg_name: If not None will try to inject the request context to the function using this - name. - :type pass_context_arg_name: str|None - """ - self.components = components or {} - - def component_get(oas3_name): - return self.components.get(oas3_name, {}) - - # operation overrides globals - security_schemes = component_get('securitySchemes') - app_security = operation.get('security', app_security) - uri_parser_class = uri_parser_class or OpenAPIURIParser - - self._router_controller = operation.get('x-openapi-router-controller') - - super().__init__( - api=api, - method=method, - path=path, - operation=operation, - resolver=resolver, - app_security=app_security, - security_schemes=security_schemes, - validate_responses=validate_responses, - strict_validation=strict_validation, - randomize_endpoint=randomize_endpoint, - validator_map=validator_map, - pythonic_params=pythonic_params, - uri_parser_class=uri_parser_class, - pass_context_arg_name=pass_context_arg_name - ) - - self._definitions_map = { - 'components': { - 'schemas': component_get('schemas'), - 'examples': component_get('examples'), - 'requestBodies': component_get('requestBodies'), - 'parameters': component_get('parameters'), - 'securitySchemes': component_get('securitySchemes'), - 'responses': component_get('responses'), - 'headers': component_get('headers'), - } - } - - self._request_body = operation.get('requestBody', {}) - - self._parameters = operation.get('parameters', []) - if path_parameters: - self._parameters += path_parameters - - self._responses = operation.get('responses', {}) - - # TODO figure out how to support multiple mimetypes - # NOTE we currently just combine all of the possible mimetypes, - # but we need to refactor to support mimetypes by response code - response_content_types = [] - for _, defn in self._responses.items(): - response_content_types += defn.get('content', {}).keys() - self._produces = response_content_types or ['application/json'] - - request_content = self._request_body.get('content', {}) - self._consumes = list(request_content.keys()) or ['application/json'] - - logger.debug('consumes: %s' % self.consumes) - logger.debug('produces: %s' % self.produces) - - @classmethod - def from_spec(cls, spec, api, path, method, resolver, *args, **kwargs): - return cls( - api, - method, - path, - spec.get_operation(path, method), - resolver=resolver, - path_parameters=spec.get_path_params(path), - app_security=spec.security, - components=spec.components, - *args, - **kwargs - ) - - @property - def request_body(self): - return self._request_body - - @property - def parameters(self): - return self._parameters - - @property - def consumes(self): - return self._consumes - - @property - def produces(self): - return self._produces - - def with_definitions(self, schema): - if self.components: - schema['schema']['components'] = self.components - return schema - - def response_schema(self, status_code=None, content_type=None): - response_definition = self.response_definition( - status_code, content_type - ) - content_definition = response_definition.get( - "content", response_definition) - content_definition = content_definition.get( - content_type, content_definition) - if "schema" in content_definition: - return self.with_definitions(content_definition).get("schema", {}) - return {} - - def example_response(self, status_code=None, content_type=None): - """ - Returns example response from spec - """ - # simply use the first/lowest status code, this is probably 200 or 201 - status_code = status_code or sorted(self._responses.keys())[0] - - content_type = content_type or self.get_mimetype() - examples_path = [str(status_code), 'content', content_type, 'examples'] - example_path = [str(status_code), 'content', content_type, 'example'] - schema_example_path = [ - str(status_code), 'content', content_type, 'schema', 'example' - ] - schema_path = [str(status_code), 'content', content_type, 'schema'] - - try: - status_code = int(status_code) - except ValueError: - status_code = 200 - try: - # TODO also use example header? - return ( - list(deep_get(self._responses, examples_path).values())[ - 0]['value'], - status_code - ) - except (KeyError, IndexError): - pass - try: - return (deep_get(self._responses, example_path), status_code) - except KeyError: - pass - try: - return (deep_get(self._responses, schema_example_path), - status_code) - except KeyError: - pass - - try: - return (self._nested_example(deep_get(self._responses, schema_path)), - status_code) - except KeyError: - return (None, status_code) - - def _nested_example(self, schema): - try: - return schema["example"] - except KeyError: - pass - try: - # Recurse if schema is an object - return {key: self._nested_example(value) - for (key, value) in schema["properties"].items()} - except KeyError: - pass - try: - # Recurse if schema is an array - return [self._nested_example(schema["items"])] - except KeyError: - raise - - def get_path_parameter_types(self): - types = {} - path_parameters = (p for p in self.parameters if p["in"] == "path") - for path_defn in path_parameters: - path_schema = path_defn["schema"] - if path_schema.get('type') == 'string' and path_schema.get('format') == 'path': - # path is special case for type 'string' - path_type = 'path' - else: - path_type = path_schema.get('type') - types[path_defn['name']] = path_type - return types - - @property - def body_schema(self): - """ - The body schema definition for this operation. - """ - return self.body_definition.get('schema', {}) - - @property - def body_definition(self): - """ - The body complete definition for this operation. - - **There can be one "body" parameter at most.** - - :rtype: dict - """ - if self._request_body: - if len(self.consumes) > 1: - logger.warning( - 'this operation accepts multiple content types, using %s', - self.consumes[0]) - res = self._request_body.get( - 'content', {}).get(self.consumes[0], {}) - return self.with_definitions(res) - return {} - - def _get_body_argument(self, body, arguments, has_kwargs, sanitize): - if len(arguments) <= 0 and not has_kwargs: - return {} - - # prefer the x-body-name as an extension of requestBody - x_body_name = sanitize(self.request_body.get('x-body-name', None)) - - if not x_body_name: - # x-body-name also accepted in the schema field for legacy firetail compat - warnings.warn('x-body-name within the requestBody schema will be deprecated in the ' - 'next major version. It should be provided directly under ' - 'the requestBody instead.', DeprecationWarning) - x_body_name = sanitize(self.body_schema.get('x-body-name', 'body')) - - # if the body came in null, and the schema says it can be null, we decide - # to include no value for the body argument, rather than the default body - if is_nullable(self.body_schema) and is_null(body): - if x_body_name in arguments or has_kwargs: - return {x_body_name: None} - return {} - - # now determine the actual value for the body (whether it came in or is default) - default_body = self.body_schema.get('default', {}) - body_props = {k: {"schema": v} for k, v - in self.body_schema.get("properties", {}).items()} - - # by OpenAPI specification `additionalProperties` defaults to `true` - # see: https://github.com/OAI/OpenAPI-Specification/blame/3.0.2/versions/3.0.2.md#L2305 - additional_props = self.body_schema.get("additionalProperties", True) - - if body is None: - body = deepcopy(default_body) - - # if the body isn't even an object, then none of the concerns below matter - if self.body_schema.get("type") != "object": - if x_body_name in arguments or has_kwargs: - return {x_body_name: body} - return {} - - # supply the initial defaults and convert all values to the proper types by schema - body_arg = deepcopy(default_body) - body_arg.update(body or {}) - - res = {} - if body_props or additional_props: - res = self._get_typed_body_values( - body_arg, body_props, additional_props) - - if x_body_name in arguments or has_kwargs: - return {x_body_name: res} - return {} - - def _get_typed_body_values(self, body_arg, body_props, additional_props): - """ - Return a copy of the provided body_arg dictionary - whose values will have the appropriate types - as defined in the provided schemas. - - :type body_arg: type dict - :type body_props: dict - :type additional_props: dict|bool - :rtype: dict - """ - additional_props_defn = {"schema": additional_props} if isinstance( - additional_props, dict) else None - res = {} - - for key, value in body_arg.items(): - try: - prop_defn = body_props[key] - res[key] = self._get_val_from_param(value, prop_defn) - except KeyError: # pragma: no cover - if not additional_props: - logger.error( - f"Body property '{key}' not defined in body schema") - continue - if additional_props_defn is not None: - value = self._get_val_from_param( - value, additional_props_defn) - res[key] = value - - return res - - def _build_default_obj_recursive(self, _properties, res): - """ takes disparate and nested default keys, and builds up a default object - """ - for key, prop in _properties.items(): - if 'default' in prop and key not in res: - res[key] = copy(prop['default']) - elif prop.get('type') == 'object' and 'properties' in prop: - res.setdefault(key, {}) - res[key] = self._build_default_obj_recursive( - prop['properties'], res[key]) - return res - - def _get_default_obj(self, schema): - try: - return deepcopy(schema["default"]) - except KeyError: - _properties = schema.get("properties", {}) - return self._build_default_obj_recursive(_properties, {}) - - def _get_query_defaults(self, query_defns): - defaults = {} - for k, v in query_defns.items(): - try: - if v["schema"]["type"] == "object": - defaults[k] = self._get_default_obj(v["schema"]) - else: - defaults[k] = v["schema"]["default"] - except KeyError: - pass - return defaults - - def _get_query_arguments(self, query, arguments, has_kwargs, sanitize): - query_defns = {p["name"]: p - for p in self.parameters - if p["in"] == "query"} - default_query_params = self._get_query_defaults(query_defns) - - query_arguments = deepcopy(default_query_params) - query_arguments = deep_merge(query_arguments, query) - return self._query_args_helper(query_defns, query_arguments, - arguments, has_kwargs, sanitize) - - def _get_val_from_param(self, value, query_defn): - query_schema = query_defn["schema"] - - if is_nullable(query_schema) and is_null(value): - return None - - if query_schema["type"] == "array": - return [make_type(part, query_schema["items"]["type"]) for part in value] - else: - return make_type(value, query_schema["type"]) +""" +This module defines an OpenAPIOperation class, a Firetail operation specific for OpenAPI 3 specs. +""" + +import logging +import warnings +from copy import copy, deepcopy + +from firetail.operations.abstract import AbstractOperation + +from ..decorators.uri_parsing import OpenAPIURIParser +from ..http_facts import FORM_CONTENT_TYPES +from ..utils import deep_get, deep_merge, is_null, is_nullable, make_type + +logger = logging.getLogger("firetail.operations.openapi3") + + +class OpenAPIOperation(AbstractOperation): + + """ + A single API operation on a path. + """ + + def __init__(self, api, method, path, operation, resolver, path_parameters=None, + app_security=None, components=None, validate_responses=False, + strict_validation=False, randomize_endpoint=None, validator_map=None, + pythonic_params=False, uri_parser_class=None, pass_context_arg_name=None): + """ + This class uses the OperationID identify the module and function that will handle the operation + + From Swagger Specification: + + **OperationID** + + A friendly name for the operation. The id MUST be unique among all operations described in the API. + Tools and libraries MAY use the operation id to uniquely identify an operation. + + :param method: HTTP method + :type method: str + :param path: + :type path: str + :param operation: swagger operation object + :type operation: dict + :param resolver: Callable that maps operationID to a function + :param path_parameters: Parameters defined in the path level + :type path_parameters: list + :param app_security: list of security rules the application uses by default + :type app_security: list + :param components: `Components Object + `_ + :type components: dict + :param validate_responses: True enables validation. Validation errors generate HTTP 500 responses. + :type validate_responses: bool + :param strict_validation: True enables validation on invalid request parameters + :type strict_validation: bool + :param randomize_endpoint: number of random characters to append to operation name + :type randomize_endpoint: integer + :param validator_map: Custom validators for the types "parameter", "body" and "response". + :type validator_map: dict + :param pythonic_params: When True CamelCase parameters are converted to snake_case and an underscore is appended + to any shadowed built-ins + :type pythonic_params: bool + :param uri_parser_class: class to use for uri parsing + :type uri_parser_class: AbstractURIParser + :param pass_context_arg_name: If not None will try to inject the request context to the function using this + name. + :type pass_context_arg_name: str|None + """ + self.components = components or {} + + def component_get(oas3_name): + return self.components.get(oas3_name, {}) + + # operation overrides globals + security_schemes = component_get('securitySchemes') + app_security = operation.get('security', app_security) + uri_parser_class = uri_parser_class or OpenAPIURIParser + + self._router_controller = operation.get('x-openapi-router-controller') + + super().__init__( + api=api, + method=method, + path=path, + operation=operation, + resolver=resolver, + app_security=app_security, + security_schemes=security_schemes, + validate_responses=validate_responses, + strict_validation=strict_validation, + randomize_endpoint=randomize_endpoint, + validator_map=validator_map, + pythonic_params=pythonic_params, + uri_parser_class=uri_parser_class, + pass_context_arg_name=pass_context_arg_name + ) + + self._definitions_map = { + 'components': { + 'schemas': component_get('schemas'), + 'examples': component_get('examples'), + 'requestBodies': component_get('requestBodies'), + 'parameters': component_get('parameters'), + 'securitySchemes': component_get('securitySchemes'), + 'responses': component_get('responses'), + 'headers': component_get('headers'), + } + } + + self._request_body = operation.get('requestBody', {}) + + self._parameters = operation.get('parameters', []) + if path_parameters: + self._parameters += path_parameters + + self._responses = operation.get('responses', {}) + + # TODO figure out how to support multiple mimetypes + # NOTE we currently just combine all of the possible mimetypes, + # but we need to refactor to support mimetypes by response code + response_content_types = [] + for _, defn in self._responses.items(): + response_content_types += defn.get('content', {}).keys() + self._produces = response_content_types or ['application/json'] + + request_content = self._request_body.get('content', {}) + self._consumes = list(request_content.keys()) or ['application/json'] + + logger.debug('consumes: %s' % self.consumes) + logger.debug('produces: %s' % self.produces) + + @classmethod + def from_spec(cls, spec, api, path, method, resolver, *args, **kwargs): + return cls( + api, + method, + path, + spec.get_operation(path, method), + resolver=resolver, + path_parameters=spec.get_path_params(path), + app_security=spec.security, + components=spec.components, + *args, + **kwargs + ) + + @property + def request_body(self): + return self._request_body + + @property + def parameters(self): + return self._parameters + + @property + def consumes(self): + return self._consumes + + @property + def produces(self): + return self._produces + + def with_definitions(self, schema): + if self.components: + schema['schema']['components'] = self.components + return schema + + def response_schema(self, status_code=None, content_type=None): + response_definition = self.response_definition( + status_code, content_type + ) + content_definition = response_definition.get("content", response_definition) + content_definition = content_definition.get(content_type, content_definition) + if "schema" in content_definition: + return self.with_definitions(content_definition).get("schema", {}) + return {} + + def example_response(self, status_code=None, content_type=None): + """ + Returns example response from spec + """ + # simply use the first/lowest status code, this is probably 200 or 201 + status_code = status_code or sorted(self._responses.keys())[0] + + content_type = content_type or self.get_mimetype() + examples_path = [str(status_code), 'content', content_type, 'examples'] + example_path = [str(status_code), 'content', content_type, 'example'] + schema_example_path = [ + str(status_code), 'content', content_type, 'schema', 'example' + ] + schema_path = [str(status_code), 'content', content_type, 'schema'] + + try: + status_code = int(status_code) + except ValueError: + status_code = 200 + try: + # TODO also use example header? + return ( + list(deep_get(self._responses, examples_path).values())[0]['value'], + status_code + ) + except (KeyError, IndexError): + pass + try: + return (deep_get(self._responses, example_path), status_code) + except KeyError: + pass + try: + return (deep_get(self._responses, schema_example_path), + status_code) + except KeyError: + pass + + try: + return (self._nested_example(deep_get(self._responses, schema_path)), + status_code) + except KeyError: + return (None, status_code) + + def _nested_example(self, schema): + try: + return schema["example"] + except KeyError: + pass + try: + # Recurse if schema is an object + return {key: self._nested_example(value) + for (key, value) in schema["properties"].items()} + except KeyError: + pass + try: + # Recurse if schema is an array + return [self._nested_example(schema["items"])] + except KeyError: + raise + + def get_path_parameter_types(self): + types = {} + path_parameters = (p for p in self.parameters if p["in"] == "path") + for path_defn in path_parameters: + path_schema = path_defn["schema"] + if path_schema.get('type') == 'string' and path_schema.get('format') == 'path': + # path is special case for type 'string' + path_type = 'path' + else: + path_type = path_schema.get('type') + types[path_defn['name']] = path_type + return types + + @property + def body_schema(self): + """ + The body schema definition for this operation. + """ + return self.body_definition.get('schema', {}) + + @property + def body_definition(self): + """ + The body complete definition for this operation. + + **There can be one "body" parameter at most.** + + :rtype: dict + """ + if self._request_body: + if len(self.consumes) > 1: + logger.warning( + 'this operation accepts multiple content types, using %s', + self.consumes[0]) + res = self._request_body.get('content', {}).get(self.consumes[0], {}) + return self.with_definitions(res) + return {} + + def _get_body_argument(self, body, arguments, has_kwargs, sanitize): + if len(arguments) <= 0 and not has_kwargs: + return {} + + # get the deprecated name from the body-schema for legacy firetail compat + x_body_name = sanitize(self.body_schema.get('x-body-name')) + + if x_body_name: + warnings.warn('x-body-name within the requestBody schema will be deprecated in the ' + 'next major version. It should be provided directly under ' + 'the requestBody instead.', DeprecationWarning) + + # prefer the x-body-name as an extension of requestBody, fallback to deprecated schema name, default 'body' + x_body_name = sanitize(self.request_body.get('x-body-name', x_body_name or 'body')) + + if self.consumes[0] in FORM_CONTENT_TYPES: + result = self._get_body_argument_form(body) + else: + result = self._get_body_argument_json(body) + + if x_body_name in arguments or has_kwargs: + return {x_body_name: result} + return {} + + def _get_body_argument_json(self, body): + # if the body came in null, and the schema says it can be null, we decide + # to include no value for the body argument, rather than the default body + if is_nullable(self.body_schema) and is_null(body): + return None + + if body is None: + default_body = self.body_schema.get('default', {}) + return deepcopy(default_body) + + return body + + def _get_body_argument_form(self, body): + # now determine the actual value for the body (whether it came in or is default) + default_body = self.body_schema.get('default', {}) + body_props = {k: {"schema": v} for k, v + in self.body_schema.get("properties", {}).items()} + + # by OpenAPI specification `additionalProperties` defaults to `true` + # see: https://github.com/OAI/OpenAPI-Specification/blame/3.0.2/versions/3.0.2.md#L2305 + additional_props = self.body_schema.get("additionalProperties", True) + + body_arg = deepcopy(default_body) + body_arg.update(body or {}) + + if body_props or additional_props: + return self._get_typed_body_values(body_arg, body_props, additional_props) + return {} + + def _get_typed_body_values(self, body_arg, body_props, additional_props): + """ + Return a copy of the provided body_arg dictionary + whose values will have the appropriate types + as defined in the provided schemas. + + :type body_arg: type dict + :type body_props: dict + :type additional_props: dict|bool + :rtype: dict + """ + additional_props_defn = {"schema": additional_props} if isinstance(additional_props, dict) else None + res = {} + + for key, value in body_arg.items(): + try: + prop_defn = body_props[key] + res[key] = self._get_val_from_param(value, prop_defn) + except KeyError: # pragma: no cover + if not additional_props: + logger.error(f"Body property '{key}' not defined in body schema") + continue + if additional_props_defn is not None: + value = self._get_val_from_param(value, additional_props_defn) + res[key] = value + + return res + + def _build_default_obj_recursive(self, _properties, res): + """ takes disparate and nested default keys, and builds up a default object + """ + for key, prop in _properties.items(): + if 'default' in prop and key not in res: + res[key] = copy(prop['default']) + elif prop.get('type') == 'object' and 'properties' in prop: + res.setdefault(key, {}) + res[key] = self._build_default_obj_recursive(prop['properties'], res[key]) + return res + + def _get_default_obj(self, schema): + try: + return deepcopy(schema["default"]) + except KeyError: + _properties = schema.get("properties", {}) + return self._build_default_obj_recursive(_properties, {}) + + def _get_query_defaults(self, query_defns): + defaults = {} + for k, v in query_defns.items(): + try: + if v["schema"]["type"] == "object": + defaults[k] = self._get_default_obj(v["schema"]) + else: + defaults[k] = v["schema"]["default"] + except KeyError: + pass + return defaults + + def _get_query_arguments(self, query, arguments, has_kwargs, sanitize): + query_defns = {p["name"]: p + for p in self.parameters + if p["in"] == "query"} + default_query_params = self._get_query_defaults(query_defns) + + query_arguments = deepcopy(default_query_params) + query_arguments = deep_merge(query_arguments, query) + return self._query_args_helper(query_defns, query_arguments, + arguments, has_kwargs, sanitize) + + def _get_val_from_param(self, value, query_defn): + query_schema = query_defn["schema"] + + if is_nullable(query_schema) and is_null(value): + return None + + if query_schema["type"] == "array": + return [make_type(part, query_schema["items"]["type"]) for part in value] + else: + return make_type(value, query_schema["type"]) diff --git a/firetail/operations/secure.py b/firetail/operations/secure.py index 91ebcbf..fe5cadf 100644 --- a/firetail/operations/secure.py +++ b/firetail/operations/secure.py @@ -1,197 +1,174 @@ -""" -This module defines a SecureOperation class, which implements the security handler for an operation. -""" - -import functools -import logging - -from ..decorators.decorator import RequestResponseDecorator - -logger = logging.getLogger("firetail.operations.secure") - -DEFAULT_MIMETYPE = 'application/json' - - -class SecureOperation: - - def __init__(self, api, security, security_schemes): - """ - :param security: list of security rules the application uses by default - :type security: list - :param security_definitions: `Security Definitions Object - `_ - :type security_definitions: dict - """ - self._api = api - self._security = security - self._security_schemes = security_schemes - - @property - def api(self): - return self._api - - @property - def security(self): - return self._security - - @property - def security_schemes(self): - return self._security_schemes - - @property - def security_decorator(self): - """ - Gets the security decorator for operation - - From Swagger Specification: - - **Security Definitions Object** - - A declaration of the security schemes available to be used in the specification. - - This does not enforce the security schemes on the operations and only serves to provide the relevant details - for each scheme. - - - **Operation Object -> security** - - A declaration of which security schemes are applied for this operation. The list of values describes alternative - security schemes that can be used (that is, there is a logical OR between the security requirements). - This definition overrides any declared top-level security. To remove a top-level security declaration, - an empty array can be used. - - - **Security Requirement Object** - - Lists the required security schemes to execute this operation. The object can have multiple security schemes - declared in it which are all required (that is, there is a logical AND between the schemes). - - The name used for each property **MUST** correspond to a security scheme declared in the Security Definitions. - - :rtype: types.FunctionType - """ - logger.debug('... Security: %s', self.security, extra=vars(self)) - if not self.security: - return self._api.security_handler_factory.security_passthrough - - auth_funcs = [] - for security_req in self.security: - if not security_req: - auth_funcs.append( - self._api.security_handler_factory.verify_none()) - continue - - sec_req_funcs = {} - oauth = False - for scheme_name, required_scopes in security_req.items(): - security_scheme = self.security_schemes[scheme_name] - - if security_scheme['type'] == 'oauth2': - if oauth: - logger.warning( - "... multiple OAuth2 security schemes in AND fashion not supported", extra=vars(self)) - break - oauth = True - token_info_func = self._api.security_handler_factory.get_tokeninfo_func( - security_scheme) - scope_validate_func = self._api.security_handler_factory.get_scope_validate_func( - security_scheme) - if not token_info_func: - logger.warning( - "... x-tokenInfoFunc missing", extra=vars(self)) - break - - sec_req_funcs[scheme_name] = self._api.security_handler_factory.verify_oauth( - token_info_func, scope_validate_func, required_scopes) - - # Swagger 2.0 - elif security_scheme['type'] == 'basic': - basic_info_func = self._api.security_handler_factory.get_basicinfo_func( - security_scheme) - if not basic_info_func: - logger.warning( - "... x-basicInfoFunc missing", extra=vars(self)) - break - - sec_req_funcs[scheme_name] = self._api.security_handler_factory.verify_basic( - basic_info_func) - - # OpenAPI 3.0.0 - elif security_scheme['type'] == 'http': - scheme = security_scheme['scheme'].lower() - if scheme == 'basic': - basic_info_func = self._api.security_handler_factory.get_basicinfo_func( - security_scheme) - if not basic_info_func: - logger.warning( - "... x-basicInfoFunc missing", extra=vars(self)) - break - - sec_req_funcs[scheme_name] = self._api.security_handler_factory.verify_basic( - basic_info_func) - elif scheme == 'bearer': - bearer_info_func = self._api.security_handler_factory.get_bearerinfo_func( - security_scheme) - if not bearer_info_func: - logger.warning( - "... x-bearerInfoFunc missing", extra=vars(self)) - break - sec_req_funcs[scheme_name] = self._api.security_handler_factory.verify_bearer( - bearer_info_func) - else: - logger.warning( - "... Unsupported http authorization scheme %s" % scheme, extra=vars(self)) - break - - elif security_scheme['type'] == 'apiKey': - scheme = security_scheme.get( - 'x-authentication-scheme', '').lower() - if scheme == 'bearer': - bearer_info_func = self._api.security_handler_factory.get_bearerinfo_func( - security_scheme) - if not bearer_info_func: - logger.warning( - "... x-bearerInfoFunc missing", extra=vars(self)) - break - sec_req_funcs[scheme_name] = self._api.security_handler_factory.verify_bearer( - bearer_info_func) - else: - apikey_info_func = self._api.security_handler_factory.get_apikeyinfo_func( - security_scheme) - if not apikey_info_func: - logger.warning( - "... x-apikeyInfoFunc missing", extra=vars(self)) - break - - sec_req_funcs[scheme_name] = self._api.security_handler_factory.verify_api_key( - apikey_info_func, security_scheme['in'], security_scheme['name'] - ) - - else: - logger.warning("... Unsupported security scheme type %s" % - security_scheme['type'], extra=vars(self)) - break - else: - # No break encountered: no missing funcs - if len(sec_req_funcs) == 1: - (func,) = sec_req_funcs.values() - auth_funcs.append(func) - else: - auth_funcs.append( - self._api.security_handler_factory.verify_multiple_schemes(sec_req_funcs)) - - return functools.partial(self._api.security_handler_factory.verify_security, auth_funcs) - - def get_mimetype(self): - return DEFAULT_MIMETYPE - - @property - def _request_response_decorator(self): - """ - Guarantees that instead of the internal representation of the - operation handler response - (firetail.lifecycle.FiretailRequest) a framework specific - object is returned. - :rtype: types.FunctionType - """ - return RequestResponseDecorator(self.api, self.get_mimetype()) +""" +This module defines a SecureOperation class, which implements the security handler for an operation. +""" + +import functools +import logging + +from ..decorators.decorator import RequestResponseDecorator + +logger = logging.getLogger("firetail.operations.secure") + +DEFAULT_MIMETYPE = 'application/json' + + +class SecureOperation: + + def __init__(self, api, security, security_schemes): + """ + :param security: list of security rules the application uses by default + :type security: list + :param security_definitions: `Security Definitions Object + `_ + :type security_definitions: dict + """ + self._api = api + self._security = security + self._security_schemes = security_schemes + + @property + def api(self): + return self._api + + @property + def security(self): + return self._security + + @property + def security_schemes(self): + return self._security_schemes + + @property + def security_decorator(self): + """ + Gets the security decorator for operation + + From Swagger Specification: + + **Security Definitions Object** + + A declaration of the security schemes available to be used in the specification. + + This does not enforce the security schemes on the operations and only serves to provide the relevant details + for each scheme. + + + **Operation Object -> security** + + A declaration of which security schemes are applied for this operation. The list of values describes alternative + security schemes that can be used (that is, there is a logical OR between the security requirements). + This definition overrides any declared top-level security. To remove a top-level security declaration, + an empty array can be used. + + + **Security Requirement Object** + + Lists the required security schemes to execute this operation. The object can have multiple security schemes + declared in it which are all required (that is, there is a logical AND between the schemes). + + The name used for each property **MUST** correspond to a security scheme declared in the Security Definitions. + + :rtype: types.FunctionType + """ + logger.debug('... Security: %s', self.security, extra=vars(self)) + if not self.security: + return self._api.security_handler_factory.security_passthrough + + auth_funcs = [] + for security_req in self.security: + if not security_req: + auth_funcs.append(self._api.security_handler_factory.verify_none()) + continue + + sec_req_funcs = {} + oauth = False + for scheme_name, required_scopes in security_req.items(): + security_scheme = self.security_schemes[scheme_name] + + if security_scheme['type'] == 'oauth2': + if oauth: + logger.warning("... multiple OAuth2 security schemes in AND fashion not supported", extra=vars(self)) + break + oauth = True + token_info_func = self._api.security_handler_factory.get_tokeninfo_func(security_scheme) + scope_validate_func = self._api.security_handler_factory.get_scope_validate_func(security_scheme) + if not token_info_func: + logger.warning("... x-tokenInfoFunc missing", extra=vars(self)) + break + + sec_req_funcs[scheme_name] = self._api.security_handler_factory.verify_oauth( + token_info_func, scope_validate_func, required_scopes) + + # Swagger 2.0 + elif security_scheme['type'] == 'basic': + basic_info_func = self._api.security_handler_factory.get_basicinfo_func(security_scheme) + if not basic_info_func: + logger.warning("... x-basicInfoFunc missing", extra=vars(self)) + break + + sec_req_funcs[scheme_name] = self._api.security_handler_factory.verify_basic(basic_info_func) + + # OpenAPI 3.0.0 + elif security_scheme['type'] == 'http': + scheme = security_scheme['scheme'].lower() + if scheme == 'basic': + basic_info_func = self._api.security_handler_factory.get_basicinfo_func(security_scheme) + if not basic_info_func: + logger.warning("... x-basicInfoFunc missing", extra=vars(self)) + break + + sec_req_funcs[scheme_name] = self._api.security_handler_factory.verify_basic(basic_info_func) + elif scheme == 'bearer': + bearer_info_func = self._api.security_handler_factory.get_bearerinfo_func(security_scheme) + if not bearer_info_func: + logger.warning("... x-bearerInfoFunc missing", extra=vars(self)) + break + sec_req_funcs[scheme_name] = self._api.security_handler_factory.verify_bearer(bearer_info_func) + else: + logger.warning("... Unsupported http authorization scheme %s" % scheme, extra=vars(self)) + break + + elif security_scheme['type'] == 'apiKey': + scheme = security_scheme.get('x-authentication-scheme', '').lower() + if scheme == 'bearer': + bearer_info_func = self._api.security_handler_factory.get_bearerinfo_func(security_scheme) + if not bearer_info_func: + logger.warning("... x-bearerInfoFunc missing", extra=vars(self)) + break + sec_req_funcs[scheme_name] = self._api.security_handler_factory.verify_bearer(bearer_info_func) + else: + apikey_info_func = self._api.security_handler_factory.get_apikeyinfo_func(security_scheme) + if not apikey_info_func: + logger.warning("... x-apikeyInfoFunc missing", extra=vars(self)) + break + + sec_req_funcs[scheme_name] = self._api.security_handler_factory.verify_api_key( + apikey_info_func, security_scheme['in'], security_scheme['name'] + ) + + else: + logger.warning("... Unsupported security scheme type %s" % security_scheme['type'], extra=vars(self)) + break + else: + # No break encountered: no missing funcs + if len(sec_req_funcs) == 1: + (func,) = sec_req_funcs.values() + auth_funcs.append(func) + else: + auth_funcs.append(self._api.security_handler_factory.verify_multiple_schemes(sec_req_funcs)) + + return functools.partial(self._api.security_handler_factory.verify_security, auth_funcs) + + def get_mimetype(self): + return DEFAULT_MIMETYPE + + @property + def _request_response_decorator(self): + """ + Guarantees that instead of the internal representation of the + operation handler response + (firetail.lifecycle.FiretailRequest) a framework specific + object is returned. + :rtype: types.FunctionType + """ + return RequestResponseDecorator(self.api, self.get_mimetype()) diff --git a/firetail/operations/swagger2.py b/firetail/operations/swagger2.py index e602900..115f21e 100644 --- a/firetail/operations/swagger2.py +++ b/firetail/operations/swagger2.py @@ -1,323 +1,318 @@ -""" -This module defines a Swagger2Operation class, a Firetail operation specific for Swagger 2 specs. -""" - -import logging -from copy import deepcopy - -from firetail.operations.abstract import AbstractOperation - -from ..decorators.uri_parsing import Swagger2URIParser -from ..exceptions import InvalidSpecification -from ..utils import deep_get, is_null, is_nullable, make_type - -logger = logging.getLogger("firetail.operations.swagger2") - - -class Swagger2Operation(AbstractOperation): - - """ - Exposes a Swagger 2.0 operation under the AbstractOperation interface. - The primary purpose of this class is to provide the `function()` method - to the API. A Swagger2Operation is plugged into the API with the provided - (path, method) pair. It resolves the handler function for this operation - with the provided resolver, and wraps the handler function with multiple - decorators that provide security, validation, serialization, - and deserialization. - """ - - def __init__(self, api, method, path, operation, resolver, app_produces, app_consumes, - path_parameters=None, app_security=None, security_definitions=None, - definitions=None, parameter_definitions=None, - response_definitions=None, validate_responses=False, strict_validation=False, - randomize_endpoint=None, validator_map=None, pythonic_params=False, - uri_parser_class=None, pass_context_arg_name=None): - """ - :param api: api that this operation is attached to - :type api: apis.AbstractAPI - :param method: HTTP method - :type method: str - :param path: relative path to this operation - :type path: str - :param operation: swagger operation object - :type operation: dict - :param resolver: Callable that maps operationID to a function - :type resolver: resolver.Resolver - :param app_produces: list of content types the application can return by default - :type app_produces: list - :param app_consumes: list of content types the application consumes by default - :type app_consumes: list - :param path_parameters: Parameters defined in the path level - :type path_parameters: list - :param app_security: list of security rules the application uses by default - :type app_security: list - :param security_definitions: `Security Definitions Object - `_ - :type security_definitions: dict - :param definitions: `Definitions Object - `_ - :type definitions: dict - :param parameter_definitions: Global parameter definitions - :type parameter_definitions: dict - :param response_definitions: Global response definitions - :type response_definitions: dict - :param validate_responses: True enables validation. Validation errors generate HTTP 500 responses. - :type validate_responses: bool - :param strict_validation: True enables validation on invalid request parameters - :type strict_validation: bool - :param randomize_endpoint: number of random characters to append to operation name - :type randomize_endpoint: integer - :param validator_map: Custom validators for the types "parameter", "body" and "response". - :type validator_map: dict - :param pythonic_params: When True CamelCase parameters are converted to snake_case and an underscore is appended - to any shadowed built-ins - :type pythonic_params: bool - :param uri_parser_class: class to use for uri parsing - :type uri_parser_class: AbstractURIParser - :param pass_context_arg_name: If not None will try to inject the request context to the function using this - name. - :type pass_context_arg_name: str|None - """ - app_security = operation.get('security', app_security) - uri_parser_class = uri_parser_class or Swagger2URIParser - - self._router_controller = operation.get('x-swagger-router-controller') - - super().__init__( - api=api, - method=method, - path=path, - operation=operation, - resolver=resolver, - app_security=app_security, - security_schemes=security_definitions, - validate_responses=validate_responses, - strict_validation=strict_validation, - randomize_endpoint=randomize_endpoint, - validator_map=validator_map, - pythonic_params=pythonic_params, - uri_parser_class=uri_parser_class, - pass_context_arg_name=pass_context_arg_name - ) - - self._produces = operation.get('produces', app_produces) - self._consumes = operation.get('consumes', app_consumes) - - self.definitions = definitions or {} - - self.definitions_map = { - 'definitions': self.definitions, - 'parameters': parameter_definitions, - 'responses': response_definitions - } - - self._parameters = operation.get('parameters', []) - if path_parameters: - self._parameters += path_parameters - - self._responses = operation.get('responses', {}) - logger.debug(self._responses) - - logger.debug('consumes: %s', self.consumes) - logger.debug('produces: %s', self.produces) - - @classmethod - def from_spec(cls, spec, api, path, method, resolver, *args, **kwargs): - return cls( - api, - method, - path, - spec.get_operation(path, method), - resolver=resolver, - path_parameters=spec.get_path_params(path), - app_security=spec.security, - app_produces=spec.produces, - app_consumes=spec.consumes, - security_definitions=spec.security_definitions, - definitions=spec.definitions, - parameter_definitions=spec.parameter_definitions, - response_definitions=spec.response_definitions, - *args, - **kwargs - ) - - @property - def parameters(self): - return self._parameters - - @property - def consumes(self): - return self._consumes - - @property - def produces(self): - return self._produces - - def get_path_parameter_types(self): - types = {} - path_parameters = (p for p in self.parameters if p["in"] == "path") - for path_defn in path_parameters: - if path_defn.get('type') == 'string' and path_defn.get('format') == 'path': - # path is special case for type 'string' - path_type = 'path' - else: - path_type = path_defn.get('type') - types[path_defn['name']] = path_type - return types - - def with_definitions(self, schema): - if "schema" in schema: - schema['schema']['definitions'] = self.definitions - return schema - - def response_schema(self, status_code=None, content_type=None): - response_definition = self.response_definition( - status_code, content_type - ) - return self.with_definitions(response_definition.get("schema", {})) - - def example_response(self, status_code=None, *args, **kwargs): - """ - Returns example response from spec - """ - # simply use the first/lowest status code, this is probably 200 or 201 - status_code = status_code or sorted(self._responses.keys())[0] - examples_path = [str(status_code), 'examples'] - schema_example_path = [str(status_code), 'schema', 'example'] - schema_path = [str(status_code), 'schema'] - - try: - status_code = int(status_code) - except ValueError: - status_code = 200 - try: - return ( - list(deep_get(self._responses, examples_path).values())[0], - status_code - ) - except KeyError: - pass - try: - return (deep_get(self._responses, schema_example_path), - status_code) - except KeyError: - pass - - try: - return (self._nested_example(deep_get(self._responses, schema_path)), - status_code) - except KeyError: - return (None, status_code) - - def _nested_example(self, schema): - try: - return schema["example"] - except KeyError: - pass - try: - # Recurse if schema is an object - return {key: self._nested_example(value) - for (key, value) in schema["properties"].items()} - except KeyError: - pass - try: - # Recurse if schema is an array - return [self._nested_example(schema["items"])] - except KeyError: - raise - - @property - def body_schema(self): - """ - The body schema definition for this operation. - """ - return self.with_definitions(self.body_definition).get('schema', {}) - - @property - def body_definition(self): - """ - The body complete definition for this operation. - - **There can be one "body" parameter at most.** - - :rtype: dict - """ - body_parameters = [p for p in self.parameters if p['in'] == 'body'] - if len(body_parameters) > 1: - raise InvalidSpecification( - "{method} {path} There can be one 'body' parameter at most".format( - method=self.method, - path=self.path)) - return body_parameters[0] if body_parameters else {} - - def _get_query_arguments(self, query, arguments, has_kwargs, sanitize): - query_defns = {p["name"]: p - for p in self.parameters - if p["in"] == "query"} - default_query_params = {k: v['default'] - for k, v in query_defns.items() - if 'default' in v} - query_arguments = deepcopy(default_query_params) - query_arguments.update(query) - return self._query_args_helper(query_defns, query_arguments, - arguments, has_kwargs, sanitize) - - def _get_body_argument(self, body, arguments, has_kwargs, sanitize): - kwargs = {} - body_parameters = [ - p for p in self.parameters if p['in'] == 'body'] or [{}] - if body is None: - body = deepcopy(body_parameters[0].get( - 'schema', {}).get('default')) - body_name = sanitize(body_parameters[0].get('name')) - - form_defns = {p['name']: p - for p in self.parameters - if p['in'] == 'formData'} - - default_form_params = {k: v['default'] - for k, v in form_defns.items() - if 'default' in v} - - # Add body parameters - if body_name: - if not has_kwargs and body_name not in arguments: - logger.debug( - "Body parameter '%s' not in function arguments", body_name) - else: - logger.debug( - "Body parameter '%s' in function arguments", body_name) - kwargs[body_name] = body - - # Add formData parameters - form_arguments = deepcopy(default_form_params) - if form_defns and body: - form_arguments.update(body) - for key, value in form_arguments.items(): - sanitized_key = sanitize(key) - if not has_kwargs and sanitized_key not in arguments: - logger.debug("FormData parameter '%s' (sanitized: '%s') not in function arguments", - key, sanitized_key) - else: - logger.debug("FormData parameter '%s' (sanitized: '%s') in function arguments", - key, sanitized_key) - try: - form_defn = form_defns[key] - except KeyError: # pragma: no cover - logger.error("Function argument '%s' (non-sanitized: %s) not defined in specification", - key, sanitized_key) - else: - kwargs[sanitized_key] = self._get_val_from_param( - value, form_defn) - return kwargs - - def _get_val_from_param(self, value, query_defn): - if is_nullable(query_defn) and is_null(value): - return None - - query_schema = query_defn - - if query_schema["type"] == "array": - return [make_type(part, query_defn["items"]["type"]) for part in value] - else: - return make_type(value, query_defn["type"]) +""" +This module defines a Swagger2Operation class, a Firetail operation specific for Swagger 2 specs. +""" + +import logging +from copy import deepcopy + +from firetail.operations.abstract import AbstractOperation + +from ..decorators.uri_parsing import Swagger2URIParser +from ..exceptions import InvalidSpecification +from ..utils import deep_get, is_null, is_nullable, make_type + +logger = logging.getLogger("firetail.operations.swagger2") + + +class Swagger2Operation(AbstractOperation): + + """ + Exposes a Swagger 2.0 operation under the AbstractOperation interface. + The primary purpose of this class is to provide the `function()` method + to the API. A Swagger2Operation is plugged into the API with the provided + (path, method) pair. It resolves the handler function for this operation + with the provided resolver, and wraps the handler function with multiple + decorators that provide security, validation, serialization, + and deserialization. + """ + + def __init__(self, api, method, path, operation, resolver, app_produces, app_consumes, + path_parameters=None, app_security=None, security_definitions=None, + definitions=None, parameter_definitions=None, + response_definitions=None, validate_responses=False, strict_validation=False, + randomize_endpoint=None, validator_map=None, pythonic_params=False, + uri_parser_class=None, pass_context_arg_name=None): + """ + :param api: api that this operation is attached to + :type api: apis.AbstractAPI + :param method: HTTP method + :type method: str + :param path: relative path to this operation + :type path: str + :param operation: swagger operation object + :type operation: dict + :param resolver: Callable that maps operationID to a function + :type resolver: resolver.Resolver + :param app_produces: list of content types the application can return by default + :type app_produces: list + :param app_consumes: list of content types the application consumes by default + :type app_consumes: list + :param path_parameters: Parameters defined in the path level + :type path_parameters: list + :param app_security: list of security rules the application uses by default + :type app_security: list + :param security_definitions: `Security Definitions Object + `_ + :type security_definitions: dict + :param definitions: `Definitions Object + `_ + :type definitions: dict + :param parameter_definitions: Global parameter definitions + :type parameter_definitions: dict + :param response_definitions: Global response definitions + :type response_definitions: dict + :param validate_responses: True enables validation. Validation errors generate HTTP 500 responses. + :type validate_responses: bool + :param strict_validation: True enables validation on invalid request parameters + :type strict_validation: bool + :param randomize_endpoint: number of random characters to append to operation name + :type randomize_endpoint: integer + :param validator_map: Custom validators for the types "parameter", "body" and "response". + :type validator_map: dict + :param pythonic_params: When True CamelCase parameters are converted to snake_case and an underscore is appended + to any shadowed built-ins + :type pythonic_params: bool + :param uri_parser_class: class to use for uri parsing + :type uri_parser_class: AbstractURIParser + :param pass_context_arg_name: If not None will try to inject the request context to the function using this + name. + :type pass_context_arg_name: str|None + """ + app_security = operation.get('security', app_security) + uri_parser_class = uri_parser_class or Swagger2URIParser + + self._router_controller = operation.get('x-swagger-router-controller') + + super().__init__( + api=api, + method=method, + path=path, + operation=operation, + resolver=resolver, + app_security=app_security, + security_schemes=security_definitions, + validate_responses=validate_responses, + strict_validation=strict_validation, + randomize_endpoint=randomize_endpoint, + validator_map=validator_map, + pythonic_params=pythonic_params, + uri_parser_class=uri_parser_class, + pass_context_arg_name=pass_context_arg_name + ) + + self._produces = operation.get('produces', app_produces) + self._consumes = operation.get('consumes', app_consumes) + + self.definitions = definitions or {} + + self.definitions_map = { + 'definitions': self.definitions, + 'parameters': parameter_definitions, + 'responses': response_definitions + } + + self._parameters = operation.get('parameters', []) + if path_parameters: + self._parameters += path_parameters + + self._responses = operation.get('responses', {}) + logger.debug(self._responses) + + logger.debug('consumes: %s', self.consumes) + logger.debug('produces: %s', self.produces) + + @classmethod + def from_spec(cls, spec, api, path, method, resolver, *args, **kwargs): + return cls( + api, + method, + path, + spec.get_operation(path, method), + resolver=resolver, + path_parameters=spec.get_path_params(path), + app_security=spec.security, + app_produces=spec.produces, + app_consumes=spec.consumes, + security_definitions=spec.security_definitions, + definitions=spec.definitions, + parameter_definitions=spec.parameter_definitions, + response_definitions=spec.response_definitions, + *args, + **kwargs + ) + + @property + def parameters(self): + return self._parameters + + @property + def consumes(self): + return self._consumes + + @property + def produces(self): + return self._produces + + def get_path_parameter_types(self): + types = {} + path_parameters = (p for p in self.parameters if p["in"] == "path") + for path_defn in path_parameters: + if path_defn.get('type') == 'string' and path_defn.get('format') == 'path': + # path is special case for type 'string' + path_type = 'path' + else: + path_type = path_defn.get('type') + types[path_defn['name']] = path_type + return types + + def with_definitions(self, schema): + if "schema" in schema: + schema['schema']['definitions'] = self.definitions + return schema + + def response_schema(self, status_code=None, content_type=None): + response_definition = self.response_definition( + status_code, content_type + ) + return self.with_definitions(response_definition.get("schema", {})) + + def example_response(self, status_code=None, *args, **kwargs): + """ + Returns example response from spec + """ + # simply use the first/lowest status code, this is probably 200 or 201 + status_code = status_code or sorted(self._responses.keys())[0] + examples_path = [str(status_code), 'examples'] + schema_example_path = [str(status_code), 'schema', 'example'] + schema_path = [str(status_code), 'schema'] + + try: + status_code = int(status_code) + except ValueError: + status_code = 200 + try: + return ( + list(deep_get(self._responses, examples_path).values())[0], + status_code + ) + except KeyError: + pass + try: + return (deep_get(self._responses, schema_example_path), + status_code) + except KeyError: + pass + + try: + return (self._nested_example(deep_get(self._responses, schema_path)), + status_code) + except KeyError: + return (None, status_code) + + def _nested_example(self, schema): + try: + return schema["example"] + except KeyError: + pass + try: + # Recurse if schema is an object + return {key: self._nested_example(value) + for (key, value) in schema["properties"].items()} + except KeyError: + pass + try: + # Recurse if schema is an array + return [self._nested_example(schema["items"])] + except KeyError: + raise + + @property + def body_schema(self): + """ + The body schema definition for this operation. + """ + return self.with_definitions(self.body_definition).get('schema', {}) + + @property + def body_definition(self): + """ + The body complete definition for this operation. + + **There can be one "body" parameter at most.** + + :rtype: dict + """ + body_parameters = [p for p in self.parameters if p['in'] == 'body'] + if len(body_parameters) > 1: + raise InvalidSpecification( + "{method} {path} There can be one 'body' parameter at most".format( + method=self.method, + path=self.path)) + return body_parameters[0] if body_parameters else {} + + def _get_query_arguments(self, query, arguments, has_kwargs, sanitize): + query_defns = {p["name"]: p + for p in self.parameters + if p["in"] == "query"} + default_query_params = {k: v['default'] + for k, v in query_defns.items() + if 'default' in v} + query_arguments = deepcopy(default_query_params) + query_arguments.update(query) + return self._query_args_helper(query_defns, query_arguments, + arguments, has_kwargs, sanitize) + + def _get_body_argument(self, body, arguments, has_kwargs, sanitize): + kwargs = {} + body_parameters = [p for p in self.parameters if p['in'] == 'body'] or [{}] + if body is None: + body = deepcopy(body_parameters[0].get('schema', {}).get('default')) + body_name = sanitize(body_parameters[0].get('name')) + + form_defns = {p['name']: p + for p in self.parameters + if p['in'] == 'formData'} + + default_form_params = {k: v['default'] + for k, v in form_defns.items() + if 'default' in v} + + # Add body parameters + if body_name: + if not has_kwargs and body_name not in arguments: + logger.debug("Body parameter '%s' not in function arguments", body_name) + else: + logger.debug("Body parameter '%s' in function arguments", body_name) + kwargs[body_name] = body + + # Add formData parameters + form_arguments = deepcopy(default_form_params) + if form_defns and body: + form_arguments.update(body) + for key, value in form_arguments.items(): + sanitized_key = sanitize(key) + if not has_kwargs and sanitized_key not in arguments: + logger.debug("FormData parameter '%s' (sanitized: '%s') not in function arguments", + key, sanitized_key) + else: + logger.debug("FormData parameter '%s' (sanitized: '%s') in function arguments", + key, sanitized_key) + try: + form_defn = form_defns[key] + except KeyError: # pragma: no cover + logger.error("Function argument '%s' (non-sanitized: %s) not defined in specification", + key, sanitized_key) + else: + kwargs[sanitized_key] = self._get_val_from_param(value, form_defn) + return kwargs + + def _get_val_from_param(self, value, query_defn): + if is_nullable(query_defn) and is_null(value): + return None + + query_schema = query_defn + + if query_schema["type"] == "array": + return [make_type(part, query_defn["items"]["type"]) for part in value] + else: + return make_type(value, query_defn["type"]) diff --git a/firetail/options.py b/firetail/options.py index dd9be2b..0c46387 100644 --- a/firetail/options.py +++ b/firetail/options.py @@ -1,156 +1,156 @@ -""" -This module defines a Firetail specific options class to pass to the Firetail App or API. -""" - -import logging -from typing import Optional # NOQA - -try: - from swagger_ui_bundle import swagger_ui_2_path, swagger_ui_3_path -except ImportError: - swagger_ui_2_path = swagger_ui_3_path = None - -from firetail.decorators.uri_parsing import AbstractURIParser - -NO_UI_MSG = """The swagger_ui directory could not be found. - Please install firetail with extra install: pip install firetail[swagger-ui] - or provide the path to your local installation by passing swagger_path= -""" - -logger = logging.getLogger("firetail.options") - - -class FiretailOptions: - """Class holding firetail specific options.""" - - def __init__(self, options=None, oas_version=(2,)): - self._options = {} - self.oas_version = oas_version - if self.oas_version >= (3, 0, 0): - self.openapi_spec_name = '/openapi.json' - self.swagger_ui_local_path = swagger_ui_3_path - else: - self.openapi_spec_name = '/swagger.json' - self.swagger_ui_local_path = swagger_ui_2_path - - if options: - self._options.update(filter_values(options)) - - def extend(self, new_values=None): - # type: (Optional[dict]) -> FiretailOptions - """ - Return a new instance of `FiretailOptions` using as default the currently - defined options. - """ - if new_values is None: - new_values = {} - - options = dict(self._options) - options.update(filter_values(new_values)) - return FiretailOptions(options, self.oas_version) - - def as_dict(self): - return self._options - - @property - def openapi_spec_available(self): - # type: () -> bool - """ - Whether to make available the OpenAPI Specification under - `openapi_spec_path`. - - Default: True - """ - deprecated_option = self._options.get('swagger_json', True) - serve_spec = self._options.get('serve_spec', deprecated_option) - if 'swagger_json' in self._options: - deprecation_warning = ("The 'swagger_json' option is deprecated. " - "Please use 'serve_spec' instead") - logger.warning(deprecation_warning) - return serve_spec - - @property - def openapi_console_ui_available(self): - # type: () -> bool - """ - Whether to make the OpenAPI Console UI available under the path - defined in `openapi_console_ui_path` option. - - Default: True - """ - if (self._options.get('swagger_ui', True) and - self.openapi_console_ui_from_dir is None): - # logger.warning(NO_UI_MSG) - return False - return self._options.get('swagger_ui', True) - - @property - def openapi_spec_path(self): - # type: () -> str - """ - Path to mount the OpenAPI Console UI and make it accessible via a browser. - - Default: /openapi.json for openapi3, otherwise /swagger.json - """ - return self._options.get('openapi_spec_path', self.openapi_spec_name) - - @property - def openapi_console_ui_path(self): - # type: () -> str - """ - Path to mount the OpenAPI Console UI and make it accessible via a browser. - - Default: /ui - """ - return self._options.get('swagger_url', '/ui') - - @property - def openapi_console_ui_from_dir(self): - # type: () -> str - """ - Custom OpenAPI Console UI directory from where Firetail will serve - the static files. - - Default: Firetail's vendored version of the OpenAPI Console UI. - """ - return self._options.get('swagger_path', self.swagger_ui_local_path) - - @property - def openapi_console_ui_config(self): - # type: () -> dict - """ - Custom OpenAPI Console UI config. - - Default: None - """ - return self._options.get('swagger_ui_config', None) - - @property - def openapi_console_ui_index_template_variables(self): - # type: () -> dict - """ - Custom variables passed to the OpenAPI Console UI template. - - Default: {} - """ - return self._options.get('swagger_ui_template_arguments', {}) - - @property - def uri_parser_class(self): - # type: () -> AbstractURIParser - """ - The class to use for parsing URIs into path and query parameters. - Default: None - """ - return self._options.get('uri_parser_class', None) - - -def filter_values(dictionary): - # type: (dict) -> dict - """ - Remove `None` value entries in the dictionary. - - :param dictionary: - :return: - """ - return {key: value for key, value in dictionary.items() if value is not None} +""" +This module defines a Firetail specific options class to pass to the Firetail App or API. +""" + +import logging +from typing import Optional # NOQA + +try: + from swagger_ui_bundle import swagger_ui_2_path, swagger_ui_3_path +except ImportError: + swagger_ui_2_path = swagger_ui_3_path = None + +from firetail.decorators.uri_parsing import AbstractURIParser + +NO_UI_MSG = """The swagger_ui directory could not be found. + Please install firetail with extra install: pip install firetail[swagger-ui] + or provide the path to your local installation by passing swagger_path= +""" + +logger = logging.getLogger("firetail.options") + + +class FiretailOptions: + """Class holding firetail specific options.""" + + def __init__(self, options=None, oas_version=(2,)): + self._options = {} + self.oas_version = oas_version + if self.oas_version >= (3, 0, 0): + self.openapi_spec_name = '/openapi.json' + self.swagger_ui_local_path = swagger_ui_3_path + else: + self.openapi_spec_name = '/swagger.json' + self.swagger_ui_local_path = swagger_ui_2_path + + if options: + self._options.update(filter_values(options)) + + def extend(self, new_values=None): + # type: (Optional[dict]) -> FiretailOptions + """ + Return a new instance of `FiretailOptions` using as default the currently + defined options. + """ + if new_values is None: + new_values = {} + + options = dict(self._options) + options.update(filter_values(new_values)) + return FiretailOptions(options, self.oas_version) + + def as_dict(self): + return self._options + + @property + def openapi_spec_available(self): + # type: () -> bool + """ + Whether to make available the OpenAPI Specification under + `openapi_spec_path`. + + Default: True + """ + deprecated_option = self._options.get('swagger_json', True) + serve_spec = self._options.get('serve_spec', deprecated_option) + if 'swagger_json' in self._options: + deprecation_warning = ("The 'swagger_json' option is deprecated. " + "Please use 'serve_spec' instead") + logger.warning(deprecation_warning) + return serve_spec + + @property + def openapi_console_ui_available(self): + # type: () -> bool + """ + Whether to make the OpenAPI Console UI available under the path + defined in `openapi_console_ui_path` option. + + Default: True + """ + if (self._options.get('swagger_ui', True) and + self.openapi_console_ui_from_dir is None): + logger.warning(NO_UI_MSG) + return False + return self._options.get('swagger_ui', True) + + @property + def openapi_spec_path(self): + # type: () -> str + """ + Path to mount the OpenAPI Console UI and make it accessible via a browser. + + Default: /openapi.json for openapi3, otherwise /swagger.json + """ + return self._options.get('openapi_spec_path', self.openapi_spec_name) + + @property + def openapi_console_ui_path(self): + # type: () -> str + """ + Path to mount the OpenAPI Console UI and make it accessible via a browser. + + Default: /ui + """ + return self._options.get('swagger_url', '/ui') + + @property + def openapi_console_ui_from_dir(self): + # type: () -> str + """ + Custom OpenAPI Console UI directory from where Firetail will serve + the static files. + + Default: Firetail's vendored version of the OpenAPI Console UI. + """ + return self._options.get('swagger_path', self.swagger_ui_local_path) + + @property + def openapi_console_ui_config(self): + # type: () -> dict + """ + Custom OpenAPI Console UI config. + + Default: None + """ + return self._options.get('swagger_ui_config', None) + + @property + def openapi_console_ui_index_template_variables(self): + # type: () -> dict + """ + Custom variables passed to the OpenAPI Console UI template. + + Default: {} + """ + return self._options.get('swagger_ui_template_arguments', {}) + + @property + def uri_parser_class(self): + # type: () -> AbstractURIParser + """ + The class to use for parsing URIs into path and query parameters. + Default: None + """ + return self._options.get('uri_parser_class', None) + + +def filter_values(dictionary): + # type: (dict) -> dict + """ + Remove `None` value entries in the dictionary. + + :param dictionary: + :return: + """ + return {key: value for key, value in dictionary.items() if value is not None} diff --git a/firetail/problem.py b/firetail/problem.py index 9524e21..e50237f 100644 --- a/firetail/problem.py +++ b/firetail/problem.py @@ -1,49 +1,50 @@ -""" -This module contains a Python interface for Problem Details for HTTP APIs -, which is a standardized format -to communicate distinct "problem types" to non-human consumers. -""" - -from .lifecycle import FiretailResponse - - -def problem(status, title, detail, type=None, instance=None, headers=None, ext=None): - """ - Returns a `Problem Details `_ error response. - - - :param status: The HTTP status code generated by the origin server for this occurrence of the problem. - :type status: int - :param title: A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to - occurrence of the problem, except for purposes of localisation. - :type title: str - :param detail: An human readable explanation specific to this occurrence of the problem. - :type detail: str - :param type: An absolute URI that identifies the problem type. When dereferenced, it SHOULD provide human-readable - documentation for the problem type (e.g., using HTML). When this member is not present its value is - assumed to be "about:blank". - :type: type: str - :param instance: An absolute URI that identifies the specific occurrence of the problem. It may or may not yield - further information if dereferenced. - :type instance: str - :param headers: HTTP headers to include in the response - :type headers: dict | None - :param ext: Extension members to include in the body - :type ext: dict | None - :return: error response - :rtype: FiretailResponse - """ - if not type: - type = 'about:blank' - - problem_response = {'type': type, 'title': title, - 'detail': detail, 'status': status} - if instance: - problem_response['instance'] = instance - if ext: - problem_response.update(ext) - - mimetype = content_type = 'application/problem+json' - return FiretailResponse(status, mimetype, content_type, - body=problem_response, - headers=headers) +""" +This module contains a Python interface for Problem Details for HTTP APIs +, which is a standardized format +to communicate distinct "problem types" to non-human consumers. +""" + +from .lifecycle import FiretailResponse + + +def problem(status, title, detail, type=None, instance=None, headers=None, ext=None): + """ + Returns a `Problem Details `_ error response. + + + :param status: The HTTP status code generated by the origin server for this occurrence of the problem. + :type status: int + :param title: A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to + occurrence of the problem, except for purposes of localisation. + :type title: str + :param detail: An human readable explanation specific to this occurrence of the problem. + :type detail: str + :param type: An absolute URI that identifies the problem type. When dereferenced, it SHOULD provide human-readable + documentation for the problem type (e.g., using HTML). When this member is not present its value is + assumed to be "about:blank". + :type: type: str + :param instance: An absolute URI that identifies the specific occurrence of the problem. It may or may not yield + further information if dereferenced. + :type instance: str + :param headers: HTTP headers to include in the response + :type headers: dict | None + :param ext: Extension members to include in the body + :type ext: dict | None + :return: error response + :rtype: FiretailResponse + """ + if not type: + type = 'about:blank' + + problem_response = {'type': type, 'title': title, 'detail': detail, 'status': status} + if instance: + problem_response['instance'] = instance + if ext: + problem_response.update(ext) + + mimetype = content_type = 'application/problem+json' + return FiretailResponse(status, + mimetype, + content_type, + body=problem_response, + headers=headers) diff --git a/firetail/resolver.py b/firetail/resolver.py index 3bb9eb4..0b723ba 100644 --- a/firetail/resolver.py +++ b/firetail/resolver.py @@ -1,250 +1,250 @@ -""" -This module contains resolvers, functions that resolves the user defined view functions -from the operations defined in the OpenAPI spec. -""" - -import inspect -import logging -import sys - -from inflection import camelize - -import firetail.utils as utils -from firetail.exceptions import ResolverError - -logger = logging.getLogger('firetail.resolver') - - -class Resolution: - def __init__(self, function, operation_id): - """ - Represents the result of operation resolution - - :param function: The endpoint function - :type function: types.FunctionType - """ - self.function = function - self.operation_id = operation_id - - -class Resolver: - def __init__(self, function_resolver=utils.get_function_from_name): - """ - Standard resolver - - :param function_resolver: Function that resolves functions using an operationId - :type function_resolver: types.FunctionType - """ - self.function_resolver = function_resolver - - def resolve(self, operation): - """ - Default operation resolver - - :type operation: firetail.operations.AbstractOperation - """ - operation_id = self.resolve_operation_id(operation) - return Resolution(self.resolve_function_from_operation_id(operation_id), operation_id) - - def resolve_operation_id(self, operation): - """ - Default operationId resolver - - :type operation: firetail.operations.AbstractOperation - """ - operation_id = operation.operation_id - router_controller = operation.router_controller - if router_controller is None: - return operation_id - return f'{router_controller}.{operation_id}' - - def resolve_function_from_operation_id(self, operation_id): - """ - Invokes the function_resolver - - :type operation_id: str - """ - try: - return self.function_resolver(operation_id) - except ImportError as e: - msg = f'Cannot resolve operationId "{operation_id}"! Import error was "{str(e)}"' - raise ResolverError(msg, sys.exc_info()) - except (AttributeError, ValueError) as e: - raise ResolverError(str(e), sys.exc_info()) - - -class RelativeResolver(Resolver): - """ - Resolves endpoint functions relative to a given root path or module. - """ - def __init__(self, root_path, function_resolver=utils.get_function_from_name): - """ - :param root_path: The root path relative to which an operationId is resolved. - Can also be a module. Has the same effect as setting - `x-swagger-router-controller` or `x-openapi-router-controller` equal to - `root_path` for every operation individually. - :type root_path: typing.Union[str, types.ModuleType] - :param function_resolver: Function that resolves functions using an operationId - :type function_resolver: types.FunctionType - """ - super().__init__(function_resolver=function_resolver) - if inspect.ismodule(root_path): - self.root_path = root_path.__name__ - else: - self.root_path = root_path - - def resolve_operation_id(self, operation): - """Resolves the operationId relative to the root path, unless - x-swagger-router-controller or x-openapi-router-controller is specified. - - :param operation: The operation to resolve - :type operation: firetail.operations.AbstractOperation - """ - operation_id = operation.operation_id - router_controller = operation.router_controller - if router_controller is None: - return f'{self.root_path}.{operation_id}' - return f'{router_controller}.{operation_id}' - - -class RestyResolver(Resolver): - """ - Resolves endpoint functions using REST semantics (unless overridden by specifying operationId) - """ - - def __init__(self, default_module_name, collection_endpoint_name='search'): - """ - :param default_module_name: Default module name for operations - :type default_module_name: str - """ - super().__init__() - self.default_module_name = default_module_name - self.collection_endpoint_name = collection_endpoint_name - - def resolve_operation_id(self, operation): - """ - Resolves the operationId using REST semantics unless explicitly configured in the spec - - :type operation: firetail.operations.AbstractOperation - """ - if operation.operation_id: - return super().resolve_operation_id(operation) - - return self.resolve_operation_id_using_rest_semantics(operation) - - def resolve_operation_id_using_rest_semantics(self, operation): - """ - Resolves the operationId using REST semantics - - :type operation: firetail.operations.AbstractOperation - """ - - # Split the path into components delimited by '/' - path_components = [c for c in operation.path.split('/') if len(c)] - - def is_var(component): - """True if the path component is a var. eg, '{id}'""" - return (component[0] == '{') and (component[-1] == '}') - - resource_name = '.'.join( - [c for c in path_components if not is_var(c)] - ).replace('-', '_') - - def get_controller_name(): - x_router_controller = operation.router_controller - - name = self.default_module_name - - if x_router_controller: - name = x_router_controller - - elif resource_name: - name += '.' + resource_name - - return name - - def get_function_name(): - method = operation.method - - is_collection_endpoint = \ - method.lower() == 'get' \ - and len(resource_name) \ - and not is_var(path_components[-1]) - - return self.collection_endpoint_name if is_collection_endpoint else method.lower() - - return f'{get_controller_name()}.{get_function_name()}' - - -class MethodViewResolver(RestyResolver): - """ - Resolves endpoint functions based on Flask's MethodView semantics, e.g. :: - - paths: - /foo_bar: - get: - # Implied function call: api.FooBarView().get - - class FooBarView(MethodView): - def get(self): - return ... - def post(self): - return ... - """ - - def __init__(self, *args, **kwargs): - super(MethodViewResolver, self).__init__(*args, **kwargs) - self.initialized_views = [] - - def resolve_operation_id(self, operation): - """ - Resolves the operationId using REST semantics unless explicitly configured in the spec - Once resolved with REST semantics the view_name is capitalised and has 'View' added - to it so it now matches the Class names of the MethodView - - :type operation: firetail.operations.AbstractOperation - """ - if operation.operation_id: - # If operation_id is defined then use the higher level API to resolve - return RestyResolver.resolve_operation_id(self, operation) - - # Use RestyResolver to get operation_id for us (follow their naming conventions/structure) - operation_id = self.resolve_operation_id_using_rest_semantics(operation) - module_name, view_base, meth_name = operation_id.rsplit('.', 2) - view_name = camelize(view_base) + 'View' - - return f"{module_name}.{view_name}.{meth_name}" - - def resolve_function_from_operation_id(self, operation_id): - """ - Invokes the function_resolver - - :type operation_id: str - """ - - try: - module_name, view_name, meth_name = operation_id.rsplit('.', 2) - if operation_id and not view_name.endswith('View'): - # If operation_id is not a view then assume it is a standard function - return self.function_resolver(operation_id) - - mod = __import__(module_name, fromlist=[view_name]) - view_cls = getattr(mod, view_name) - # Find the class and instantiate it - view = None - for v in self.initialized_views: - if v.__class__ == view_cls: - view = v - break - if view is None: - view = view_cls() - self.initialized_views.append(view) - func = getattr(view, meth_name) - # Return the method function of the class - return func - except ImportError as e: - msg = 'Cannot resolve operationId "{}"! Import error was "{}"'.format( - operation_id, str(e)) - raise ResolverError(msg, sys.exc_info()) - except (AttributeError, ValueError) as e: - raise ResolverError(str(e), sys.exc_info()) +""" +This module contains resolvers, functions that resolves the user defined view functions +from the operations defined in the OpenAPI spec. +""" + +import inspect +import logging +import sys + +from inflection import camelize + +import firetail.utils as utils +from firetail.exceptions import ResolverError + +logger = logging.getLogger('firetail.resolver') + + +class Resolution: + def __init__(self, function, operation_id): + """ + Represents the result of operation resolution + + :param function: The endpoint function + :type function: types.FunctionType + """ + self.function = function + self.operation_id = operation_id + + +class Resolver: + def __init__(self, function_resolver=utils.get_function_from_name): + """ + Standard resolver + + :param function_resolver: Function that resolves functions using an operationId + :type function_resolver: types.FunctionType + """ + self.function_resolver = function_resolver + + def resolve(self, operation): + """ + Default operation resolver + + :type operation: firetail.operations.AbstractOperation + """ + operation_id = self.resolve_operation_id(operation) + return Resolution(self.resolve_function_from_operation_id(operation_id), operation_id) + + def resolve_operation_id(self, operation): + """ + Default operationId resolver + + :type operation: firetail.operations.AbstractOperation + """ + operation_id = operation.operation_id + router_controller = operation.router_controller + if router_controller is None: + return operation_id + return f'{router_controller}.{operation_id}' + + def resolve_function_from_operation_id(self, operation_id): + """ + Invokes the function_resolver + + :type operation_id: str + """ + try: + return self.function_resolver(operation_id) + except ImportError as e: + msg = f'Cannot resolve operationId "{operation_id}"! Import error was "{str(e)}"' + raise ResolverError(msg, sys.exc_info()) + except (AttributeError, ValueError) as e: + raise ResolverError(str(e), sys.exc_info()) + + +class RelativeResolver(Resolver): + """ + Resolves endpoint functions relative to a given root path or module. + """ + def __init__(self, root_path, function_resolver=utils.get_function_from_name): + """ + :param root_path: The root path relative to which an operationId is resolved. + Can also be a module. Has the same effect as setting + `x-swagger-router-controller` or `x-openapi-router-controller` equal to + `root_path` for every operation individually. + :type root_path: typing.Union[str, types.ModuleType] + :param function_resolver: Function that resolves functions using an operationId + :type function_resolver: types.FunctionType + """ + super().__init__(function_resolver=function_resolver) + if inspect.ismodule(root_path): + self.root_path = root_path.__name__ + else: + self.root_path = root_path + + def resolve_operation_id(self, operation): + """Resolves the operationId relative to the root path, unless + x-swagger-router-controller or x-openapi-router-controller is specified. + + :param operation: The operation to resolve + :type operation: firetail.operations.AbstractOperation + """ + operation_id = operation.operation_id + router_controller = operation.router_controller + if router_controller is None: + return f'{self.root_path}.{operation_id}' + return f'{router_controller}.{operation_id}' + + +class RestyResolver(Resolver): + """ + Resolves endpoint functions using REST semantics (unless overridden by specifying operationId) + """ + + def __init__(self, default_module_name, collection_endpoint_name='search'): + """ + :param default_module_name: Default module name for operations + :type default_module_name: str + """ + super().__init__() + self.default_module_name = default_module_name + self.collection_endpoint_name = collection_endpoint_name + + def resolve_operation_id(self, operation): + """ + Resolves the operationId using REST semantics unless explicitly configured in the spec + + :type operation: firetail.operations.AbstractOperation + """ + if operation.operation_id: + return super().resolve_operation_id(operation) + + return self.resolve_operation_id_using_rest_semantics(operation) + + def resolve_operation_id_using_rest_semantics(self, operation): + """ + Resolves the operationId using REST semantics + + :type operation: firetail.operations.AbstractOperation + """ + + # Split the path into components delimited by '/' + path_components = [c for c in operation.path.split('/') if len(c)] + + def is_var(component): + """True if the path component is a var. eg, '{id}'""" + return (component[0] == '{') and (component[-1] == '}') + + resource_name = '.'.join( + [c for c in path_components if not is_var(c)] + ).replace('-', '_') + + def get_controller_name(): + x_router_controller = operation.router_controller + + name = self.default_module_name + + if x_router_controller: + name = x_router_controller + + elif resource_name: + name += '.' + resource_name + + return name + + def get_function_name(): + method = operation.method + + is_collection_endpoint = \ + method.lower() == 'get' \ + and len(resource_name) \ + and not is_var(path_components[-1]) + + return self.collection_endpoint_name if is_collection_endpoint else method.lower() + + return f'{get_controller_name()}.{get_function_name()}' + + +class MethodViewResolver(RestyResolver): + """ + Resolves endpoint functions based on Flask's MethodView semantics, e.g. :: + + paths: + /foo_bar: + get: + # Implied function call: api.FooBarView().get + + class FooBarView(MethodView): + def get(self): + return ... + def post(self): + return ... + """ + + def __init__(self, *args, **kwargs): + super(MethodViewResolver, self).__init__(*args, **kwargs) + self.initialized_views = [] + + def resolve_operation_id(self, operation): + """ + Resolves the operationId using REST semantics unless explicitly configured in the spec + Once resolved with REST semantics the view_name is capitalised and has 'View' added + to it so it now matches the Class names of the MethodView + + :type operation: firetail.operations.AbstractOperation + """ + if operation.operation_id: + # If operation_id is defined then use the higher level API to resolve + return RestyResolver.resolve_operation_id(self, operation) + + # Use RestyResolver to get operation_id for us (follow their naming conventions/structure) + operation_id = self.resolve_operation_id_using_rest_semantics(operation) + module_name, view_base, meth_name = operation_id.rsplit('.', 2) + view_name = camelize(view_base) + 'View' + + return f"{module_name}.{view_name}.{meth_name}" + + def resolve_function_from_operation_id(self, operation_id): + """ + Invokes the function_resolver + + :type operation_id: str + """ + + try: + module_name, view_name, meth_name = operation_id.rsplit('.', 2) + if operation_id and not view_name.endswith('View'): + # If operation_id is not a view then assume it is a standard function + return self.function_resolver(operation_id) + + mod = __import__(module_name, fromlist=[view_name]) + view_cls = getattr(mod, view_name) + # Find the class and instantiate it + view = None + for v in self.initialized_views: + if v.__class__ == view_cls: + view = v + break + if view is None: + view = view_cls() + self.initialized_views.append(view) + func = getattr(view, meth_name) + # Return the method function of the class + return func + except ImportError as e: + msg = 'Cannot resolve operationId "{}"! Import error was "{}"'.format( + operation_id, str(e)) + raise ResolverError(msg, sys.exc_info()) + except (AttributeError, ValueError) as e: + raise ResolverError(str(e), sys.exc_info()) diff --git a/firetail/resources/schemas/v2.0/schema.json b/firetail/resources/schemas/v2.0/schema.json index 312aa75..e63aef6 100644 --- a/firetail/resources/schemas/v2.0/schema.json +++ b/firetail/resources/schemas/v2.0/schema.json @@ -1,1591 +1,1591 @@ -{ - "title": "A JSON Schema for Swagger 2.0 API.", - "id": "http://swagger.io/v2/schema.json#", - "$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.", - "$ref": "#/definitions/mediaTypeList" - }, - "produces": { - "description": "A list of MIME types the API can produce.", - "$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" - } - }, - "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" - ], - "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.", - "$ref": "#/definitions/mediaTypeList" - }, - "consumes": { - "description": "A list of MIME types the API can consume.", - "$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" - ] - }, - "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" - } - } - }, - "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": { - "$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": {} - }, - "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" - } - } - } - } +{ + "title": "A JSON Schema for Swagger 2.0 API.", + "id": "http://swagger.io/v2/schema.json#", + "$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.", + "$ref": "#/definitions/mediaTypeList" + }, + "produces": { + "description": "A list of MIME types the API can produce.", + "$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" + } + }, + "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" + ], + "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.", + "$ref": "#/definitions/mediaTypeList" + }, + "consumes": { + "description": "A list of MIME types the API can consume.", + "$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" + ] + }, + "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" + } + } + }, + "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": { + "$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": {} + }, + "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/firetail/resources/schemas/v3.0/schema.json b/firetail/resources/schemas/v3.0/schema.json index f8e63a2..7180840 100644 --- a/firetail/resources/schemas/v3.0/schema.json +++ b/firetail/resources/schemas/v3.0/schema.json @@ -1,1654 +1,1654 @@ -{ - "id": "https://spec.openapis.org/oas/3.0/schema/2019-04-02", - "$schema": "http://json-schema.org/draft-04/schema#", - "description": "Validation schema for OpenAPI Specification 3.0.X.", - "type": "object", - "required": [ - "openapi", - "info", - "paths" - ], - "properties": { - "openapi": { - "type": "string", - "pattern": "^3\\.0\\.\\d(-.+)?$" - }, - "info": { - "$ref": "#/definitions/Info" - }, - "externalDocs": { - "$ref": "#/definitions/ExternalDocumentation" - }, - "servers": { - "type": "array", - "items": { - "$ref": "#/definitions/Server" - } - }, - "security": { - "type": "array", - "items": { - "$ref": "#/definitions/SecurityRequirement" - } - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/definitions/Tag" - }, - "uniqueItems": true - }, - "paths": { - "$ref": "#/definitions/Paths" - }, - "components": { - "$ref": "#/definitions/Components" - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false, - "definitions": { - "Reference": { - "type": "object", - "required": [ - "$ref" - ], - "patternProperties": { - "^\\$ref$": { - "type": "string", - "format": "uri-reference" - } - } - }, - "Info": { - "type": "object", - "required": [ - "title", - "version" - ], - "properties": { - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "termsOfService": { - "type": "string", - "format": "uri-reference" - }, - "contact": { - "$ref": "#/definitions/Contact" - }, - "license": { - "$ref": "#/definitions/License" - }, - "version": { - "type": "string" - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false - }, - "Contact": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "url": { - "type": "string", - "format": "uri-reference" - }, - "email": { - "type": "string", - "format": "email" - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false - }, - "License": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "url": { - "type": "string", - "format": "uri-reference" - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false - }, - "Server": { - "type": "object", - "required": [ - "url" - ], - "properties": { - "url": { - "type": "string" - }, - "description": { - "type": "string" - }, - "variables": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/ServerVariable" - } - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false - }, - "ServerVariable": { - "type": "object", - "required": [ - "default" - ], - "properties": { - "enum": { - "type": "array", - "items": { - "type": "string" - } - }, - "default": { - "type": "string" - }, - "description": { - "type": "string" - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false - }, - "Components": { - "type": "object", - "properties": { - "schemas": { - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9\\.\\-_]+$": { - "oneOf": [ - { - "$ref": "#/definitions/Schema" - }, - { - "$ref": "#/definitions/Reference" - } - ] - } - } - }, - "responses": { - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9\\.\\-_]+$": { - "oneOf": [ - { - "$ref": "#/definitions/Reference" - }, - { - "$ref": "#/definitions/Response" - } - ] - } - } - }, - "parameters": { - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9\\.\\-_]+$": { - "oneOf": [ - { - "$ref": "#/definitions/Reference" - }, - { - "$ref": "#/definitions/Parameter" - } - ] - } - } - }, - "examples": { - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9\\.\\-_]+$": { - "oneOf": [ - { - "$ref": "#/definitions/Reference" - }, - { - "$ref": "#/definitions/Example" - } - ] - } - } - }, - "requestBodies": { - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9\\.\\-_]+$": { - "oneOf": [ - { - "$ref": "#/definitions/Reference" - }, - { - "$ref": "#/definitions/RequestBody" - } - ] - } - } - }, - "headers": { - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9\\.\\-_]+$": { - "oneOf": [ - { - "$ref": "#/definitions/Reference" - }, - { - "$ref": "#/definitions/Header" - } - ] - } - } - }, - "securitySchemes": { - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9\\.\\-_]+$": { - "oneOf": [ - { - "$ref": "#/definitions/Reference" - }, - { - "$ref": "#/definitions/SecurityScheme" - } - ] - } - } - }, - "links": { - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9\\.\\-_]+$": { - "oneOf": [ - { - "$ref": "#/definitions/Reference" - }, - { - "$ref": "#/definitions/Link" - } - ] - } - } - }, - "callbacks": { - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9\\.\\-_]+$": { - "oneOf": [ - { - "$ref": "#/definitions/Reference" - }, - { - "$ref": "#/definitions/Callback" - } - ] - } - } - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false - }, - "Schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "multipleOf": { - "type": "number", - "minimum": 0, - "exclusiveMinimum": true - }, - "maximum": { - "type": "number" - }, - "exclusiveMaximum": { - "type": "boolean", - "default": false - }, - "minimum": { - "type": "number" - }, - "exclusiveMinimum": { - "type": "boolean", - "default": false - }, - "maxLength": { - "type": "integer", - "minimum": 0 - }, - "minLength": { - "type": "integer", - "minimum": 0, - "default": 0 - }, - "pattern": { - "type": "string", - "format": "regex" - }, - "maxItems": { - "type": "integer", - "minimum": 0 - }, - "minItems": { - "type": "integer", - "minimum": 0, - "default": 0 - }, - "uniqueItems": { - "type": "boolean", - "default": false - }, - "maxProperties": { - "type": "integer", - "minimum": 0 - }, - "minProperties": { - "type": "integer", - "minimum": 0, - "default": 0 - }, - "required": { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1, - "uniqueItems": true - }, - "enum": { - "type": "array", - "items": { - }, - "minItems": 1, - "uniqueItems": false - }, - "type": { - "type": "string", - "enum": [ - "array", - "boolean", - "integer", - "number", - "object", - "string" - ] - }, - "not": { - "oneOf": [ - { - "$ref": "#/definitions/Schema" - }, - { - "$ref": "#/definitions/Reference" - } - ] - }, - "allOf": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/definitions/Schema" - }, - { - "$ref": "#/definitions/Reference" - } - ] - } - }, - "oneOf": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/definitions/Schema" - }, - { - "$ref": "#/definitions/Reference" - } - ] - } - }, - "anyOf": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/definitions/Schema" - }, - { - "$ref": "#/definitions/Reference" - } - ] - } - }, - "items": { - "oneOf": [ - { - "$ref": "#/definitions/Schema" - }, - { - "$ref": "#/definitions/Reference" - } - ] - }, - "properties": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "$ref": "#/definitions/Schema" - }, - { - "$ref": "#/definitions/Reference" - } - ] - } - }, - "additionalProperties": { - "oneOf": [ - { - "$ref": "#/definitions/Schema" - }, - { - "$ref": "#/definitions/Reference" - }, - { - "type": "boolean" - } - ], - "default": true - }, - "description": { - "type": "string" - }, - "format": { - "type": "string" - }, - "default": { - }, - "nullable": { - "type": "boolean", - "default": false - }, - "discriminator": { - "$ref": "#/definitions/Discriminator" - }, - "readOnly": { - "type": "boolean", - "default": false - }, - "writeOnly": { - "type": "boolean", - "default": false - }, - "example": { - }, - "externalDocs": { - "$ref": "#/definitions/ExternalDocumentation" - }, - "deprecated": { - "type": "boolean", - "default": false - }, - "xml": { - "$ref": "#/definitions/XML" - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false - }, - "Discriminator": { - "type": "object", - "required": [ - "propertyName" - ], - "properties": { - "propertyName": { - "type": "string" - }, - "mapping": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - }, - "XML": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "namespace": { - "type": "string", - "format": "uri" - }, - "prefix": { - "type": "string" - }, - "attribute": { - "type": "boolean", - "default": false - }, - "wrapped": { - "type": "boolean", - "default": false - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false - }, - "Response": { - "type": "object", - "required": [ - "description" - ], - "properties": { - "description": { - "type": "string" - }, - "headers": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "$ref": "#/definitions/Header" - }, - { - "$ref": "#/definitions/Reference" - } - ] - } - }, - "content": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/MediaType" - } - }, - "links": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "$ref": "#/definitions/Link" - }, - { - "$ref": "#/definitions/Reference" - } - ] - } - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false - }, - "MediaType": { - "type": "object", - "properties": { - "schema": { - "oneOf": [ - { - "$ref": "#/definitions/Schema" - }, - { - "$ref": "#/definitions/Reference" - } - ] - }, - "example": { - }, - "examples": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "$ref": "#/definitions/Example" - }, - { - "$ref": "#/definitions/Reference" - } - ] - } - }, - "encoding": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/Encoding" - } - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false, - "allOf": [ - { - "$ref": "#/definitions/ExampleXORExamples" - } - ] - }, - "Example": { - "type": "object", - "properties": { - "summary": { - "type": "string" - }, - "description": { - "type": "string" - }, - "value": { - }, - "externalValue": { - "type": "string", - "format": "uri-reference" - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false - }, - "Header": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "required": { - "type": "boolean", - "default": false - }, - "deprecated": { - "type": "boolean", - "default": false - }, - "allowEmptyValue": { - "type": "boolean", - "default": false - }, - "style": { - "type": "string", - "enum": [ - "simple" - ], - "default": "simple" - }, - "explode": { - "type": "boolean" - }, - "allowReserved": { - "type": "boolean", - "default": false - }, - "schema": { - "oneOf": [ - { - "$ref": "#/definitions/Schema" - }, - { - "$ref": "#/definitions/Reference" - } - ] - }, - "content": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/MediaType" - }, - "minProperties": 1, - "maxProperties": 1 - }, - "example": { - }, - "examples": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "$ref": "#/definitions/Example" - }, - { - "$ref": "#/definitions/Reference" - } - ] - } - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false, - "allOf": [ - { - "$ref": "#/definitions/ExampleXORExamples" - }, - { - "$ref": "#/definitions/SchemaXORContent" - } - ] - }, - "Paths": { - "type": "object", - "patternProperties": { - "^\\/": { - "$ref": "#/definitions/PathItem" - }, - "^x-": { - } - }, - "additionalProperties": false - }, - "PathItem": { - "type": "object", - "properties": { - "$ref": { - "type": "string" - }, - "summary": { - "type": "string" - }, - "description": { - "type": "string" - }, - "servers": { - "type": "array", - "items": { - "$ref": "#/definitions/Server" - } - }, - "parameters": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/definitions/Parameter" - }, - { - "$ref": "#/definitions/Reference" - } - ] - }, - "uniqueItems": true - } - }, - "patternProperties": { - "^(get|put|post|delete|options|head|patch|trace)$": { - "$ref": "#/definitions/Operation" - }, - "^x-": { - } - }, - "additionalProperties": false - }, - "Operation": { - "type": "object", - "required": [ - "responses" - ], - "properties": { - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "summary": { - "type": "string" - }, - "description": { - "type": "string" - }, - "externalDocs": { - "$ref": "#/definitions/ExternalDocumentation" - }, - "operationId": { - "type": "string" - }, - "parameters": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/definitions/Parameter" - }, - { - "$ref": "#/definitions/Reference" - } - ] - }, - "uniqueItems": true - }, - "requestBody": { - "oneOf": [ - { - "$ref": "#/definitions/RequestBody" - }, - { - "$ref": "#/definitions/Reference" - } - ] - }, - "responses": { - "$ref": "#/definitions/Responses" - }, - "callbacks": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "$ref": "#/definitions/Callback" - }, - { - "$ref": "#/definitions/Reference" - } - ] - } - }, - "deprecated": { - "type": "boolean", - "default": false - }, - "security": { - "type": "array", - "items": { - "$ref": "#/definitions/SecurityRequirement" - } - }, - "servers": { - "type": "array", - "items": { - "$ref": "#/definitions/Server" - } - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false - }, - "Responses": { - "type": "object", - "properties": { - "default": { - "oneOf": [ - { - "$ref": "#/definitions/Response" - }, - { - "$ref": "#/definitions/Reference" - } - ] - } - }, - "patternProperties": { - "^[1-5](?:\\d{2}|XX)$": { - "oneOf": [ - { - "$ref": "#/definitions/Response" - }, - { - "$ref": "#/definitions/Reference" - } - ] - }, - "^x-": { - } - }, - "minProperties": 1, - "additionalProperties": false - }, - "SecurityRequirement": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "Tag": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "externalDocs": { - "$ref": "#/definitions/ExternalDocumentation" - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false - }, - "ExternalDocumentation": { - "type": "object", - "required": [ - "url" - ], - "properties": { - "description": { - "type": "string" - }, - "url": { - "type": "string", - "format": "uri-reference" - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false - }, - "ExampleXORExamples": { - "description": "Example and examples are mutually exclusive", - "not": { - "required": [ - "example", - "examples" - ] - } - }, - "SchemaXORContent": { - "description": "Schema and content are mutually exclusive, at least one is required", - "not": { - "required": [ - "schema", - "content" - ] - }, - "oneOf": [ - { - "required": [ - "schema" - ] - }, - { - "required": [ - "content" - ], - "description": "Some properties are not allowed if content is present", - "allOf": [ - { - "not": { - "required": [ - "style" - ] - } - }, - { - "not": { - "required": [ - "explode" - ] - } - }, - { - "not": { - "required": [ - "allowReserved" - ] - } - }, - { - "not": { - "required": [ - "example" - ] - } - }, - { - "not": { - "required": [ - "examples" - ] - } - } - ] - } - ] - }, - "Parameter": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "in": { - "type": "string" - }, - "description": { - "type": "string" - }, - "required": { - "type": "boolean", - "default": false - }, - "deprecated": { - "type": "boolean", - "default": false - }, - "allowEmptyValue": { - "type": "boolean", - "default": false - }, - "style": { - "type": "string" - }, - "explode": { - "type": "boolean" - }, - "allowReserved": { - "type": "boolean", - "default": false - }, - "schema": { - "oneOf": [ - { - "$ref": "#/definitions/Schema" - }, - { - "$ref": "#/definitions/Reference" - } - ] - }, - "content": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/MediaType" - }, - "minProperties": 1, - "maxProperties": 1 - }, - "example": { - }, - "examples": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "$ref": "#/definitions/Example" - }, - { - "$ref": "#/definitions/Reference" - } - ] - } - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false, - "required": [ - "name", - "in" - ], - "allOf": [ - { - "$ref": "#/definitions/ExampleXORExamples" - }, - { - "$ref": "#/definitions/SchemaXORContent" - }, - { - "$ref": "#/definitions/ParameterLocation" - } - ] - }, - "ParameterLocation": { - "description": "Parameter location", - "oneOf": [ - { - "description": "Parameter in path", - "required": [ - "required" - ], - "properties": { - "in": { - "enum": [ - "path" - ] - }, - "style": { - "enum": [ - "matrix", - "label", - "simple" - ], - "default": "simple" - }, - "required": { - "enum": [ - true - ] - } - } - }, - { - "description": "Parameter in query", - "properties": { - "in": { - "enum": [ - "query" - ] - }, - "style": { - "enum": [ - "form", - "spaceDelimited", - "pipeDelimited", - "deepObject" - ], - "default": "form" - } - } - }, - { - "description": "Parameter in header", - "properties": { - "in": { - "enum": [ - "header" - ] - }, - "style": { - "enum": [ - "simple" - ], - "default": "simple" - } - } - }, - { - "description": "Parameter in cookie", - "properties": { - "in": { - "enum": [ - "cookie" - ] - }, - "style": { - "enum": [ - "form" - ], - "default": "form" - } - } - } - ] - }, - "RequestBody": { - "type": "object", - "required": [ - "content" - ], - "properties": { - "description": { - "type": "string" - }, - "content": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/MediaType" - } - }, - "required": { - "type": "boolean", - "default": false - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false - }, - "SecurityScheme": { - "oneOf": [ - { - "$ref": "#/definitions/APIKeySecurityScheme" - }, - { - "$ref": "#/definitions/HTTPSecurityScheme" - }, - { - "$ref": "#/definitions/OAuth2SecurityScheme" - }, - { - "$ref": "#/definitions/OpenIdConnectSecurityScheme" - } - ] - }, - "APIKeySecurityScheme": { - "type": "object", - "required": [ - "type", - "name", - "in" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "apiKey" - ] - }, - "name": { - "type": "string" - }, - "in": { - "type": "string", - "enum": [ - "header", - "query", - "cookie" - ] - }, - "description": { - "type": "string" - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false - }, - "HTTPSecurityScheme": { - "type": "object", - "required": [ - "scheme", - "type" - ], - "properties": { - "scheme": { - "type": "string" - }, - "bearerFormat": { - "type": "string" - }, - "description": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "http" - ] - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false, - "oneOf": [ - { - "description": "Bearer", - "properties": { - "scheme": { - "enum": [ - "bearer" - ] - } - } - }, - { - "description": "Non Bearer", - "not": { - "required": [ - "bearerFormat" - ] - }, - "properties": { - "scheme": { - "not": { - "enum": [ - "bearer" - ] - } - } - } - } - ] - }, - "OAuth2SecurityScheme": { - "type": "object", - "required": [ - "type", - "flows" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "oauth2" - ] - }, - "flows": { - "$ref": "#/definitions/OAuthFlows" - }, - "description": { - "type": "string" - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false - }, - "OpenIdConnectSecurityScheme": { - "type": "object", - "required": [ - "type", - "openIdConnectUrl" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "openIdConnect" - ] - }, - "openIdConnectUrl": { - "type": "string", - "format": "uri-reference" - }, - "description": { - "type": "string" - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false - }, - "OAuthFlows": { - "type": "object", - "properties": { - "implicit": { - "$ref": "#/definitions/ImplicitOAuthFlow" - }, - "password": { - "$ref": "#/definitions/PasswordOAuthFlow" - }, - "clientCredentials": { - "$ref": "#/definitions/ClientCredentialsFlow" - }, - "authorizationCode": { - "$ref": "#/definitions/AuthorizationCodeOAuthFlow" - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false - }, - "ImplicitOAuthFlow": { - "type": "object", - "required": [ - "authorizationUrl", - "scopes" - ], - "properties": { - "authorizationUrl": { - "type": "string", - "format": "uri-reference" - }, - "refreshUrl": { - "type": "string", - "format": "uri-reference" - }, - "scopes": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false - }, - "PasswordOAuthFlow": { - "type": "object", - "required": [ - "tokenUrl" - ], - "properties": { - "tokenUrl": { - "type": "string", - "format": "uri-reference" - }, - "refreshUrl": { - "type": "string", - "format": "uri-reference" - }, - "scopes": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false - }, - "ClientCredentialsFlow": { - "type": "object", - "required": [ - "tokenUrl" - ], - "properties": { - "tokenUrl": { - "type": "string", - "format": "uri-reference" - }, - "refreshUrl": { - "type": "string", - "format": "uri-reference" - }, - "scopes": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false - }, - "AuthorizationCodeOAuthFlow": { - "type": "object", - "required": [ - "authorizationUrl", - "tokenUrl" - ], - "properties": { - "authorizationUrl": { - "type": "string", - "format": "uri-reference" - }, - "tokenUrl": { - "type": "string", - "format": "uri-reference" - }, - "refreshUrl": { - "type": "string", - "format": "uri-reference" - }, - "scopes": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false - }, - "Link": { - "type": "object", - "properties": { - "operationId": { - "type": "string" - }, - "operationRef": { - "type": "string", - "format": "uri-reference" - }, - "parameters": { - "type": "object", - "additionalProperties": { - } - }, - "requestBody": { - }, - "description": { - "type": "string" - }, - "server": { - "$ref": "#/definitions/Server" - } - }, - "patternProperties": { - "^x-": { - } - }, - "additionalProperties": false, - "not": { - "description": "Operation Id and Operation Ref are mutually exclusive", - "required": [ - "operationId", - "operationRef" - ] - } - }, - "Callback": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/PathItem" - }, - "patternProperties": { - "^x-": { - } - } - }, - "Encoding": { - "type": "object", - "properties": { - "contentType": { - "type": "string" - }, - "headers": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/Header" - } - }, - "style": { - "type": "string", - "enum": [ - "form", - "spaceDelimited", - "pipeDelimited", - "deepObject" - ] - }, - "explode": { - "type": "boolean" - }, - "allowReserved": { - "type": "boolean", - "default": false - } - }, - "additionalProperties": false - } - } +{ + "id": "https://spec.openapis.org/oas/3.0/schema/2019-04-02", + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Validation schema for OpenAPI Specification 3.0.X.", + "type": "object", + "required": [ + "openapi", + "info", + "paths" + ], + "properties": { + "openapi": { + "type": "string", + "pattern": "^3\\.0\\.\\d(-.+)?$" + }, + "info": { + "$ref": "#/definitions/Info" + }, + "externalDocs": { + "$ref": "#/definitions/ExternalDocumentation" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/definitions/Server" + } + }, + "security": { + "type": "array", + "items": { + "$ref": "#/definitions/SecurityRequirement" + } + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/Tag" + }, + "uniqueItems": true + }, + "paths": { + "$ref": "#/definitions/Paths" + }, + "components": { + "$ref": "#/definitions/Components" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false, + "definitions": { + "Reference": { + "type": "object", + "required": [ + "$ref" + ], + "patternProperties": { + "^\\$ref$": { + "type": "string", + "format": "uri-reference" + } + } + }, + "Info": { + "type": "object", + "required": [ + "title", + "version" + ], + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "termsOfService": { + "type": "string", + "format": "uri-reference" + }, + "contact": { + "$ref": "#/definitions/Contact" + }, + "license": { + "$ref": "#/definitions/License" + }, + "version": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "Contact": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri-reference" + }, + "email": { + "type": "string", + "format": "email" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "License": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri-reference" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "Server": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string" + }, + "description": { + "type": "string" + }, + "variables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/ServerVariable" + } + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "ServerVariable": { + "type": "object", + "required": [ + "default" + ], + "properties": { + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "default": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "Components": { + "type": "object", + "properties": { + "schemas": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + } + }, + "responses": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/Response" + } + ] + } + } + }, + "parameters": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/Parameter" + } + ] + } + } + }, + "examples": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/Example" + } + ] + } + } + }, + "requestBodies": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/RequestBody" + } + ] + } + } + }, + "headers": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/Header" + } + ] + } + } + }, + "securitySchemes": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/SecurityScheme" + } + ] + } + } + }, + "links": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/Link" + } + ] + } + } + }, + "callbacks": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/Callback" + } + ] + } + } + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "Schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "multipleOf": { + "type": "number", + "minimum": 0, + "exclusiveMinimum": true + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "boolean", + "default": false + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "boolean", + "default": false + }, + "maxLength": { + "type": "integer", + "minimum": 0 + }, + "minLength": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "pattern": { + "type": "string", + "format": "regex" + }, + "maxItems": { + "type": "integer", + "minimum": 0 + }, + "minItems": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "maxProperties": { + "type": "integer", + "minimum": 0 + }, + "minProperties": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "required": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + }, + "enum": { + "type": "array", + "items": { + }, + "minItems": 1, + "uniqueItems": false + }, + "type": { + "type": "string", + "enum": [ + "array", + "boolean", + "integer", + "number", + "object", + "string" + ] + }, + "not": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "allOf": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "oneOf": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "anyOf": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + }, + { + "type": "boolean" + } + ], + "default": true + }, + "description": { + "type": "string" + }, + "format": { + "type": "string" + }, + "default": { + }, + "nullable": { + "type": "boolean", + "default": false + }, + "discriminator": { + "$ref": "#/definitions/Discriminator" + }, + "readOnly": { + "type": "boolean", + "default": false + }, + "writeOnly": { + "type": "boolean", + "default": false + }, + "example": { + }, + "externalDocs": { + "$ref": "#/definitions/ExternalDocumentation" + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "xml": { + "$ref": "#/definitions/XML" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "Discriminator": { + "type": "object", + "required": [ + "propertyName" + ], + "properties": { + "propertyName": { + "type": "string" + }, + "mapping": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "XML": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "namespace": { + "type": "string", + "format": "uri" + }, + "prefix": { + "type": "string" + }, + "attribute": { + "type": "boolean", + "default": false + }, + "wrapped": { + "type": "boolean", + "default": false + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "Response": { + "type": "object", + "required": [ + "description" + ], + "properties": { + "description": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Header" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "content": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/MediaType" + } + }, + "links": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Link" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "MediaType": { + "type": "object", + "properties": { + "schema": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "example": { + }, + "examples": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Example" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "encoding": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Encoding" + } + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false, + "allOf": [ + { + "$ref": "#/definitions/ExampleXORExamples" + } + ] + }, + "Example": { + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + }, + "externalValue": { + "type": "string", + "format": "uri-reference" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "Header": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "allowEmptyValue": { + "type": "boolean", + "default": false + }, + "style": { + "type": "string", + "enum": [ + "simple" + ], + "default": "simple" + }, + "explode": { + "type": "boolean" + }, + "allowReserved": { + "type": "boolean", + "default": false + }, + "schema": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "content": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/MediaType" + }, + "minProperties": 1, + "maxProperties": 1 + }, + "example": { + }, + "examples": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Example" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false, + "allOf": [ + { + "$ref": "#/definitions/ExampleXORExamples" + }, + { + "$ref": "#/definitions/SchemaXORContent" + } + ] + }, + "Paths": { + "type": "object", + "patternProperties": { + "^\\/": { + "$ref": "#/definitions/PathItem" + }, + "^x-": { + } + }, + "additionalProperties": false + }, + "PathItem": { + "type": "object", + "properties": { + "$ref": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/definitions/Server" + } + }, + "parameters": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Parameter" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "uniqueItems": true + } + }, + "patternProperties": { + "^(get|put|post|delete|options|head|patch|trace)$": { + "$ref": "#/definitions/Operation" + }, + "^x-": { + } + }, + "additionalProperties": false + }, + "Operation": { + "type": "object", + "required": [ + "responses" + ], + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/definitions/ExternalDocumentation" + }, + "operationId": { + "type": "string" + }, + "parameters": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Parameter" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "uniqueItems": true + }, + "requestBody": { + "oneOf": [ + { + "$ref": "#/definitions/RequestBody" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "responses": { + "$ref": "#/definitions/Responses" + }, + "callbacks": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Callback" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "security": { + "type": "array", + "items": { + "$ref": "#/definitions/SecurityRequirement" + } + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/definitions/Server" + } + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "Responses": { + "type": "object", + "properties": { + "default": { + "oneOf": [ + { + "$ref": "#/definitions/Response" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "patternProperties": { + "^[1-5](?:\\d{2}|XX)$": { + "oneOf": [ + { + "$ref": "#/definitions/Response" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "^x-": { + } + }, + "minProperties": 1, + "additionalProperties": false + }, + "SecurityRequirement": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "Tag": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/definitions/ExternalDocumentation" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "ExternalDocumentation": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "description": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri-reference" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "ExampleXORExamples": { + "description": "Example and examples are mutually exclusive", + "not": { + "required": [ + "example", + "examples" + ] + } + }, + "SchemaXORContent": { + "description": "Schema and content are mutually exclusive, at least one is required", + "not": { + "required": [ + "schema", + "content" + ] + }, + "oneOf": [ + { + "required": [ + "schema" + ] + }, + { + "required": [ + "content" + ], + "description": "Some properties are not allowed if content is present", + "allOf": [ + { + "not": { + "required": [ + "style" + ] + } + }, + { + "not": { + "required": [ + "explode" + ] + } + }, + { + "not": { + "required": [ + "allowReserved" + ] + } + }, + { + "not": { + "required": [ + "example" + ] + } + }, + { + "not": { + "required": [ + "examples" + ] + } + } + ] + } + ] + }, + "Parameter": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "in": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "allowEmptyValue": { + "type": "boolean", + "default": false + }, + "style": { + "type": "string" + }, + "explode": { + "type": "boolean" + }, + "allowReserved": { + "type": "boolean", + "default": false + }, + "schema": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "content": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/MediaType" + }, + "minProperties": 1, + "maxProperties": 1 + }, + "example": { + }, + "examples": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Example" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false, + "required": [ + "name", + "in" + ], + "allOf": [ + { + "$ref": "#/definitions/ExampleXORExamples" + }, + { + "$ref": "#/definitions/SchemaXORContent" + }, + { + "$ref": "#/definitions/ParameterLocation" + } + ] + }, + "ParameterLocation": { + "description": "Parameter location", + "oneOf": [ + { + "description": "Parameter in path", + "required": [ + "required" + ], + "properties": { + "in": { + "enum": [ + "path" + ] + }, + "style": { + "enum": [ + "matrix", + "label", + "simple" + ], + "default": "simple" + }, + "required": { + "enum": [ + true + ] + } + } + }, + { + "description": "Parameter in query", + "properties": { + "in": { + "enum": [ + "query" + ] + }, + "style": { + "enum": [ + "form", + "spaceDelimited", + "pipeDelimited", + "deepObject" + ], + "default": "form" + } + } + }, + { + "description": "Parameter in header", + "properties": { + "in": { + "enum": [ + "header" + ] + }, + "style": { + "enum": [ + "simple" + ], + "default": "simple" + } + } + }, + { + "description": "Parameter in cookie", + "properties": { + "in": { + "enum": [ + "cookie" + ] + }, + "style": { + "enum": [ + "form" + ], + "default": "form" + } + } + } + ] + }, + "RequestBody": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "description": { + "type": "string" + }, + "content": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/MediaType" + } + }, + "required": { + "type": "boolean", + "default": false + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "SecurityScheme": { + "oneOf": [ + { + "$ref": "#/definitions/APIKeySecurityScheme" + }, + { + "$ref": "#/definitions/HTTPSecurityScheme" + }, + { + "$ref": "#/definitions/OAuth2SecurityScheme" + }, + { + "$ref": "#/definitions/OpenIdConnectSecurityScheme" + } + ] + }, + "APIKeySecurityScheme": { + "type": "object", + "required": [ + "type", + "name", + "in" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "apiKey" + ] + }, + "name": { + "type": "string" + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "cookie" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "HTTPSecurityScheme": { + "type": "object", + "required": [ + "scheme", + "type" + ], + "properties": { + "scheme": { + "type": "string" + }, + "bearerFormat": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "http" + ] + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false, + "oneOf": [ + { + "description": "Bearer", + "properties": { + "scheme": { + "enum": [ + "bearer" + ] + } + } + }, + { + "description": "Non Bearer", + "not": { + "required": [ + "bearerFormat" + ] + }, + "properties": { + "scheme": { + "not": { + "enum": [ + "bearer" + ] + } + } + } + } + ] + }, + "OAuth2SecurityScheme": { + "type": "object", + "required": [ + "type", + "flows" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "oauth2" + ] + }, + "flows": { + "$ref": "#/definitions/OAuthFlows" + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "OpenIdConnectSecurityScheme": { + "type": "object", + "required": [ + "type", + "openIdConnectUrl" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "openIdConnect" + ] + }, + "openIdConnectUrl": { + "type": "string", + "format": "uri-reference" + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "OAuthFlows": { + "type": "object", + "properties": { + "implicit": { + "$ref": "#/definitions/ImplicitOAuthFlow" + }, + "password": { + "$ref": "#/definitions/PasswordOAuthFlow" + }, + "clientCredentials": { + "$ref": "#/definitions/ClientCredentialsFlow" + }, + "authorizationCode": { + "$ref": "#/definitions/AuthorizationCodeOAuthFlow" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "ImplicitOAuthFlow": { + "type": "object", + "required": [ + "authorizationUrl", + "scopes" + ], + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "PasswordOAuthFlow": { + "type": "object", + "required": [ + "tokenUrl" + ], + "properties": { + "tokenUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "ClientCredentialsFlow": { + "type": "object", + "required": [ + "tokenUrl" + ], + "properties": { + "tokenUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "AuthorizationCodeOAuthFlow": { + "type": "object", + "required": [ + "authorizationUrl", + "tokenUrl" + ], + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri-reference" + }, + "tokenUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "Link": { + "type": "object", + "properties": { + "operationId": { + "type": "string" + }, + "operationRef": { + "type": "string", + "format": "uri-reference" + }, + "parameters": { + "type": "object", + "additionalProperties": { + } + }, + "requestBody": { + }, + "description": { + "type": "string" + }, + "server": { + "$ref": "#/definitions/Server" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false, + "not": { + "description": "Operation Id and Operation Ref are mutually exclusive", + "required": [ + "operationId", + "operationRef" + ] + } + }, + "Callback": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/PathItem" + }, + "patternProperties": { + "^x-": { + } + } + }, + "Encoding": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Header" + } + }, + "style": { + "type": "string", + "enum": [ + "form", + "spaceDelimited", + "pipeDelimited", + "deepObject" + ] + }, + "explode": { + "type": "boolean" + }, + "allowReserved": { + "type": "boolean", + "default": false + } + }, + "additionalProperties": false + } + } } \ No newline at end of file diff --git a/firetail/security/__init__.py b/firetail/security/__init__.py index 103e73d..2404ea4 100644 --- a/firetail/security/__init__.py +++ b/firetail/security/__init__.py @@ -1,18 +1,23 @@ -""" -This module defines SecurityHandlerFactories which support the creation of security -handlers for operations. - -isort:skip_file -""" - -# abstract -from .async_security_handler_factory import AbstractAsyncSecurityHandlerFactory # NOQA -from .security_handler_factory import AbstractSecurityHandlerFactory # NOQA - -from ..utils import not_installed_error - -# concrete -try: - from .flask_security_handler_factory import FlaskSecurityHandlerFactory -except ImportError as err: # pragma: no cover - FlaskSecurityHandlerFactory = not_installed_error(err) +""" +This module defines SecurityHandlerFactories which support the creation of security +handlers for operations. + +isort:skip_file +""" + +# abstract +from .async_security_handler_factory import AbstractAsyncSecurityHandlerFactory # NOQA +from .security_handler_factory import AbstractSecurityHandlerFactory # NOQA + +from ..utils import not_installed_error + +# concrete +try: + from .flask_security_handler_factory import FlaskSecurityHandlerFactory +except ImportError as err: # pragma: no cover + FlaskSecurityHandlerFactory = not_installed_error(err) + +try: + from .aiohttp_security_handler_factory import AioHttpSecurityHandlerFactory +except ImportError as err: # pragma: no cover + AioHttpSecurityHandlerFactory = not_installed_error(err) diff --git a/firetail/security/aiohttp_security_handler_factory.py b/firetail/security/aiohttp_security_handler_factory.py new file mode 100644 index 0000000..6750828 --- /dev/null +++ b/firetail/security/aiohttp_security_handler_factory.py @@ -0,0 +1,39 @@ +""" +This module defines an aiohttp-specific SecurityHandlerFactory. +""" + +import logging + +import aiohttp + +from .async_security_handler_factory import AbstractAsyncSecurityHandlerFactory + +logger = logging.getLogger('firetail.api.security') + + +class AioHttpSecurityHandlerFactory(AbstractAsyncSecurityHandlerFactory): + def __init__(self, pass_context_arg_name): + super().__init__(pass_context_arg_name=pass_context_arg_name) + self.client_session = None + + def get_token_info_remote(self, token_info_url): + """ + Return a function which will call `token_info_url` to retrieve token info. + + Returned function must accept oauth token in parameter. + It must return a token_info dict in case of success, None otherwise. + + :param token_info_url: Url to get information about the token + :type token_info_url: str + :rtype: types.FunctionType + """ + async def wrapper(token): + if not self.client_session: + # Must be created in a coroutine + self.client_session = aiohttp.ClientSession() + headers = {'Authorization': f'Bearer {token}'} + token_request = await self.client_session.get(token_info_url, headers=headers, timeout=5) + if token_request.status != 200: + return None + return token_request.json() + return wrapper diff --git a/firetail/security/async_security_handler_factory.py b/firetail/security/async_security_handler_factory.py index bd8d156..eec4605 100644 --- a/firetail/security/async_security_handler_factory.py +++ b/firetail/security/async_security_handler_factory.py @@ -1,105 +1,105 @@ -""" -This module defines an abstract asynchronous SecurityHandlerFactory which supports the creation of -asynchronous security handlers for coroutine operations. -""" - -import abc -import asyncio -import functools -import logging - -from ..exceptions import OAuthProblem, OAuthResponseProblem, OAuthScopeProblem -from .security_handler_factory import AbstractSecurityHandlerFactory - -logger = logging.getLogger('firetail.api.security') - - -class AbstractAsyncSecurityHandlerFactory(AbstractSecurityHandlerFactory): - def _generic_check(self, func, exception_msg): - need_to_add_context, need_to_add_required_scopes = self._need_to_add_context_or_scopes(func) - - async def wrapper(request, *args, required_scopes=None): - kwargs = {} - if need_to_add_context: - kwargs[self.pass_context_arg_name] = request.context - if need_to_add_required_scopes: - kwargs[self.required_scopes_kw] = required_scopes - token_info = func(*args, **kwargs) - while asyncio.iscoroutine(token_info): - token_info = await token_info - if token_info is self.no_value: - return self.no_value - if token_info is None: - raise OAuthResponseProblem(description=exception_msg, token_response=None) - return token_info - - return wrapper - - def check_oauth_func(self, token_info_func, scope_validate_func): - get_token_info = self._generic_check(token_info_func, 'Provided token is not valid') - need_to_add_context, _ = self._need_to_add_context_or_scopes(scope_validate_func) - - async def wrapper(request, token, required_scopes): - token_info = await get_token_info(request, token, required_scopes=required_scopes) - - # Fallback to 'scopes' for backward compatibility - token_scopes = token_info.get('scope', token_info.get('scopes', '')) - - kwargs = {} - if need_to_add_context: - kwargs[self.pass_context_arg_name] = request.context - validation = scope_validate_func(required_scopes, token_scopes, **kwargs) - while asyncio.iscoroutine(validation): - validation = await validation - if not validation: - raise OAuthScopeProblem( - description='Provided token doesn\'t have the required scope', - required_scopes=required_scopes, - token_scopes=token_scopes - ) - - return token_info - return wrapper - - @classmethod - def verify_security(cls, auth_funcs, function): - @functools.wraps(function) - async def wrapper(request): - token_info = cls.no_value - errors = [] - for func in auth_funcs: - try: - token_info = func(request) - while asyncio.iscoroutine(token_info): - token_info = await token_info - if token_info is not cls.no_value: - break - except Exception as err: - errors.append(err) - - if token_info is cls.no_value: - if errors != []: - cls._raise_most_specific(errors) - else: - logger.info("... No auth provided. Aborting with 401.") - raise OAuthProblem(description='No authorization token provided') - - # Fallback to 'uid' for backward compatibility - request.context['user'] = token_info.get('sub', token_info.get('uid')) - request.context['token_info'] = token_info - return function(request) - - return wrapper - - @abc.abstractmethod - def get_token_info_remote(self, token_info_url): - """ - Return a function which will call `token_info_url` to retrieve token info. - - Returned function must accept oauth token in parameter. - It must return a token_info dict in case of success, None otherwise. - - :param token_info_url: Url to get information about the token - :type token_info_url: str - :rtype: types.FunctionType - """ +""" +This module defines an abstract asynchronous SecurityHandlerFactory which supports the creation of +asynchronous security handlers for coroutine operations. +""" + +import abc +import asyncio +import functools +import logging + +from ..exceptions import OAuthProblem, OAuthResponseProblem, OAuthScopeProblem +from .security_handler_factory import AbstractSecurityHandlerFactory + +logger = logging.getLogger('firetail.api.security') + + +class AbstractAsyncSecurityHandlerFactory(AbstractSecurityHandlerFactory): + def _generic_check(self, func, exception_msg): + need_to_add_context, need_to_add_required_scopes = self._need_to_add_context_or_scopes(func) + + async def wrapper(request, *args, required_scopes=None): + kwargs = {} + if need_to_add_context: + kwargs[self.pass_context_arg_name] = request.context + if need_to_add_required_scopes: + kwargs[self.required_scopes_kw] = required_scopes + token_info = func(*args, **kwargs) + while asyncio.iscoroutine(token_info): + token_info = await token_info + if token_info is self.no_value: + return self.no_value + if token_info is None: + raise OAuthResponseProblem(description=exception_msg, token_response=None) + return token_info + + return wrapper + + def check_oauth_func(self, token_info_func, scope_validate_func): + get_token_info = self._generic_check(token_info_func, 'Provided token is not valid') + need_to_add_context, _ = self._need_to_add_context_or_scopes(scope_validate_func) + + async def wrapper(request, token, required_scopes): + token_info = await get_token_info(request, token, required_scopes=required_scopes) + + # Fallback to 'scopes' for backward compatibility + token_scopes = token_info.get('scope', token_info.get('scopes', '')) + + kwargs = {} + if need_to_add_context: + kwargs[self.pass_context_arg_name] = request.context + validation = scope_validate_func(required_scopes, token_scopes, **kwargs) + while asyncio.iscoroutine(validation): + validation = await validation + if not validation: + raise OAuthScopeProblem( + description='Provided token doesn\'t have the required scope', + required_scopes=required_scopes, + token_scopes=token_scopes + ) + + return token_info + return wrapper + + @classmethod + def verify_security(cls, auth_funcs, function): + @functools.wraps(function) + async def wrapper(request): + token_info = cls.no_value + errors = [] + for func in auth_funcs: + try: + token_info = func(request) + while asyncio.iscoroutine(token_info): + token_info = await token_info + if token_info is not cls.no_value: + break + except Exception as err: + errors.append(err) + + else: + if errors != []: + cls._raise_most_specific(errors) + else: + logger.info("... No auth provided. Aborting with 401.") + raise OAuthProblem(description='No authorization token provided') + + # Fallback to 'uid' for backward compatibility + request.context['user'] = token_info.get('sub', token_info.get('uid')) + request.context['token_info'] = token_info + return function(request) + + return wrapper + + @abc.abstractmethod + def get_token_info_remote(self, token_info_url): + """ + Return a function which will call `token_info_url` to retrieve token info. + + Returned function must accept oauth token in parameter. + It must return a token_info dict in case of success, None otherwise. + + :param token_info_url: Url to get information about the token + :type token_info_url: str + :rtype: types.FunctionType + """ diff --git a/firetail/security/flask_security_handler_factory.py b/firetail/security/flask_security_handler_factory.py index de7207d..610aa18 100644 --- a/firetail/security/flask_security_handler_factory.py +++ b/firetail/security/flask_security_handler_factory.py @@ -1,40 +1,40 @@ -""" -This module defines a Flask-specific SecurityHandlerFactory. -""" - -import requests - -from .security_handler_factory import AbstractSecurityHandlerFactory - -# use connection pool for OAuth tokeninfo -adapter = requests.adapters.HTTPAdapter(pool_connections=100, pool_maxsize=100) -session = requests.Session() -session.mount('http://', adapter) -session.mount('https://', adapter) - - -class FlaskSecurityHandlerFactory(AbstractSecurityHandlerFactory): - def get_token_info_remote(self, token_info_url): - """ - Return a function which will call `token_info_url` to retrieve token info. - - Returned function must accept oauth token in parameter. - It must return a token_info dict in case of success, None otherwise. - - :param token_info_url: Url to get information about the token - :type token_info_url: str - :rtype: types.FunctionType - """ - def wrapper(token): - """ - Retrieve oauth token_info remotely using HTTP - :param token: oauth token from authorization header - :type token: str - :rtype: dict - """ - headers = {'Authorization': f'Bearer {token}'} - token_request = session.get(token_info_url, headers=headers, timeout=5) - if not token_request.ok: - return None - return token_request.json() - return wrapper +""" +This module defines a Flask-specific SecurityHandlerFactory. +""" + +import requests + +from .security_handler_factory import AbstractSecurityHandlerFactory + +# use connection pool for OAuth tokeninfo +adapter = requests.adapters.HTTPAdapter(pool_connections=100, pool_maxsize=100) +session = requests.Session() +session.mount('http://', adapter) +session.mount('https://', adapter) + + +class FlaskSecurityHandlerFactory(AbstractSecurityHandlerFactory): + def get_token_info_remote(self, token_info_url): + """ + Return a function which will call `token_info_url` to retrieve token info. + + Returned function must accept oauth token in parameter. + It must return a token_info dict in case of success, None otherwise. + + :param token_info_url: Url to get information about the token + :type token_info_url: str + :rtype: types.FunctionType + """ + def wrapper(token): + """ + Retrieve oauth token_info remotely using HTTP + :param token: oauth token from authorization header + :type token: str + :rtype: dict + """ + headers = {'Authorization': f'Bearer {token}'} + token_request = session.get(token_info_url, headers=headers, timeout=5) + if not token_request.ok: + return None + return token_request.json() + return wrapper diff --git a/firetail/security/security_handler_factory.py b/firetail/security/security_handler_factory.py index cd737a2..3c1ff08 100644 --- a/firetail/security/security_handler_factory.py +++ b/firetail/security/security_handler_factory.py @@ -1,449 +1,438 @@ -""" -This module defines an abstract SecurityHandlerFactory which supports the creation of security -handlers for operations. -""" - -import abc -import base64 -import functools -import http.cookies -import logging -import os -import textwrap -import typing as t - -from ..decorators.parameter import inspect_function_arguments -from ..exceptions import (FiretailException, OAuthProblem, - OAuthResponseProblem, OAuthScopeProblem) -from ..utils import get_function_from_name - -logger = logging.getLogger('firetail.api.security') - - -class AbstractSecurityHandlerFactory(abc.ABC): - """ - get_*_func -> _get_function -> get_function_from_name (name=security function defined in spec) - (if url defined instead of a function -> get_token_info_remote) - - std security functions: security_{passthrough,deny} - - verify_* -> returns a security wrapper around the security function - check_* -> returns a function tasked with doing auth for use inside the verify wrapper - check helpers (used outside wrappers): _need_to_add_context_or_scopes - the security function - - verify helpers (used inside wrappers): get_auth_header_value, get_cookie_value - """ - no_value = object() - required_scopes_kw = 'required_scopes' - - def __init__(self, pass_context_arg_name): - self.pass_context_arg_name = pass_context_arg_name - - @staticmethod - def _get_function(security_definition, security_definition_key, environ_key, default=None): - """ - Return function by getting its name from security_definition or environment variable - """ - func = security_definition.get( - security_definition_key) or os.environ.get(environ_key) - if func: - return get_function_from_name(func) - return default - - def get_tokeninfo_func(self, security_definition: dict) -> t.Optional[t.Callable]: - """ - :type security_definition: dict - - >>> get_tokeninfo_url({'x-tokenInfoFunc': 'foo.bar'}) - '' - """ - token_info_func = self._get_function( - security_definition, "x-tokenInfoFunc", 'TOKENINFO_FUNC') - if token_info_func: - return token_info_func - - token_info_url = (security_definition.get('x-tokenInfoUrl') or - os.environ.get('TOKENINFO_URL')) - if token_info_url: - return self.get_token_info_remote(token_info_url) - - return None - - @classmethod - def get_scope_validate_func(cls, security_definition): - """ - :type security_definition: dict - :rtype: function - - >>> get_scope_validate_func({'x-scopeValidateFunc': 'foo.bar'}) - '' - """ - return cls._get_function(security_definition, "x-scopeValidateFunc", 'SCOPEVALIDATE_FUNC', cls.validate_scope) - - @classmethod - def get_basicinfo_func(cls, security_definition): - """ - :type security_definition: dict - :rtype: function - - >>> get_basicinfo_func({'x-basicInfoFunc': 'foo.bar'}) - '' - """ - return cls._get_function(security_definition, "x-basicInfoFunc", 'BASICINFO_FUNC') - - @classmethod - def get_apikeyinfo_func(cls, security_definition): - """ - :type security_definition: dict - :rtype: function - - >>> get_apikeyinfo_func({'x-apikeyInfoFunc': 'foo.bar'}) - '' - """ - return cls._get_function(security_definition, "x-apikeyInfoFunc", 'APIKEYINFO_FUNC') - - @classmethod - def get_bearerinfo_func(cls, security_definition): - """ - :type security_definition: dict - :rtype: function - - >>> get_bearerinfo_func({'x-bearerInfoFunc': 'foo.bar'}) - '' - """ - return cls._get_function(security_definition, "x-bearerInfoFunc", 'BEARERINFO_FUNC') - - @staticmethod - def security_passthrough(function): - """ - :type function: types.FunctionType - :rtype: types.FunctionType - """ - return function - - @staticmethod - def security_deny(function): - """ - :type function: types.FunctionType - :rtype: types.FunctionType - """ - - def deny(*args, **kwargs): - raise FiretailException("Error in security definitions") - - return deny - - @staticmethod - def validate_scope(required_scopes, token_scopes): - """ - :param required_scopes: Scopes required to access operation - :param token_scopes: Scopes granted by authorization server - :rtype: bool - """ - required_scopes = set(required_scopes) - if isinstance(token_scopes, list): - token_scopes = set(token_scopes) - else: - token_scopes = set(token_scopes.split()) - logger.debug("... Scopes required: %s", required_scopes) - logger.debug("... Token scopes: %s", token_scopes) - if not required_scopes <= token_scopes: - logger.info(textwrap.dedent(""" - ... Token scopes (%s) do not match the scopes necessary to call endpoint (%s). - Aborting with 403.""").replace('\n', ''), - token_scopes, required_scopes) - return False - return True - - @staticmethod - def get_auth_header_value(request): - """ - Called inside security wrapper functions - - Return Authorization type and value if any. - If not Authorization, return (None, None) - Raise OAuthProblem for invalid Authorization header - """ - authorization = request.headers.get('Authorization') - if not authorization: - return None, None - - try: - auth_type, value = authorization.split(None, 1) - except ValueError: - raise OAuthProblem(description='Invalid authorization header') - return auth_type.lower(), value - - def verify_oauth(self, token_info_func, scope_validate_func, required_scopes): - check_oauth_func = self.check_oauth_func( - token_info_func, scope_validate_func) - - def wrapper(request): - auth_type, token = self.get_auth_header_value(request) - if auth_type != 'bearer': - return self.no_value - - return check_oauth_func(request, token, required_scopes=required_scopes) - - return wrapper - - def verify_basic(self, basic_info_func): - check_basic_info_func = self.check_basic_auth(basic_info_func) - - def wrapper(request): - auth_type, user_pass = self.get_auth_header_value(request) - if auth_type != 'basic': - return self.no_value - - try: - username, password = base64.b64decode( - user_pass).decode('latin1').split(':', 1) - except Exception: - raise OAuthProblem(description='Invalid authorization header') - - return check_basic_info_func(request, username, password) - - return wrapper - - @staticmethod - def get_cookie_value(cookies, name): - ''' - Called inside security wrapper functions - - Returns cookie value by its name. None if no such value. - :param cookies: str: cookies raw data - :param name: str: cookies key - ''' - cookie_parser = http.cookies.SimpleCookie() - cookie_parser.load(str(cookies)) - try: - return cookie_parser[name].value - except KeyError: - return None - - def verify_api_key(self, api_key_info_func, loc, name): - check_api_key_func = self.check_api_key(api_key_info_func) - - def wrapper(request): - - def _immutable_pop(_dict, key): - """ - Pops the key from an immutable dict and returns the value that was popped, - and a new immutable dict without the popped key. - """ - cls = type(_dict) - try: - _dict = _dict.to_dict(flat=False) - return _dict.pop(key)[0], cls(_dict) - except AttributeError: - _dict = dict(_dict.items()) - return _dict.pop(key), cls(_dict) - - if loc == 'query': - try: - api_key, request.query = _immutable_pop( - request.query, name) - except KeyError: - api_key = None - elif loc == 'header': - api_key = request.headers.get(name) - elif loc == 'cookie': - cookie_list = request.headers.get('Cookie') - api_key = self.get_cookie_value(cookie_list, name) - else: - return self.no_value - - if api_key is None: - return self.no_value - - return check_api_key_func(request, api_key) - - return wrapper - - def verify_bearer(self, token_info_func): - """ - :param token_info_func: types.FunctionType - :rtype: types.FunctionType - """ - check_bearer_func = self.check_bearer_token(token_info_func) - - def wrapper(request): - auth_type, token = self.get_auth_header_value(request) - if auth_type != 'bearer': - return self.no_value - return check_bearer_func(request, token) - - return wrapper - - def verify_multiple_schemes(self, schemes): - """ - Verifies multiple authentication schemes in AND fashion. - If any scheme fails, the entire authentication fails. - - :param schemes: mapping scheme_name to auth function - :type schemes: dict - :rtype: types.FunctionType - """ - - def wrapper(request): - token_info = {} - for scheme_name, func in schemes.items(): - result = func(request) - if result is self.no_value: - return self.no_value - token_info[scheme_name] = result - - return token_info - - return wrapper - - @staticmethod - def verify_none(): - """ - :rtype: types.FunctionType - """ - - def wrapper(request): - return {} - - return wrapper - - def _need_to_add_context_or_scopes(self, func): - arguments, has_kwargs = inspect_function_arguments(func) - need_context = self.pass_context_arg_name and ( - has_kwargs or self.pass_context_arg_name in arguments) - need_required_scopes = has_kwargs or self.required_scopes_kw in arguments - return need_context, need_required_scopes - - def _generic_check(self, func, exception_msg): - need_to_add_context, need_to_add_required_scopes = self._need_to_add_context_or_scopes( - func) - - def wrapper(request, *args, required_scopes=None): - kwargs = {} - if need_to_add_context: - kwargs[self.pass_context_arg_name] = request.context - if need_to_add_required_scopes: - kwargs[self.required_scopes_kw] = required_scopes - token_info = func(*args, **kwargs) - if token_info is self.no_value: - return self.no_value - if token_info is None: - raise OAuthResponseProblem( - description=exception_msg, token_response=None) - return token_info - - return wrapper - - def check_bearer_token(self, token_info_func): - return self._generic_check(token_info_func, 'Provided token is not valid') - - def check_basic_auth(self, basic_info_func): - return self._generic_check(basic_info_func, 'Provided authorization is not valid') - - def check_api_key(self, api_key_info_func): - return self._generic_check(api_key_info_func, 'Provided apikey is not valid') - - def check_oauth_func(self, token_info_func, scope_validate_func): - get_token_info = self._generic_check( - token_info_func, 'Provided token is not valid') - need_to_add_context, _ = self._need_to_add_context_or_scopes( - scope_validate_func) - - def wrapper(request, token, required_scopes): - token_info = get_token_info( - request, token, required_scopes=required_scopes) - - # Fallback to 'scopes' for backward compatibility - token_scopes = token_info.get( - 'scope', token_info.get('scopes', '')) - - kwargs = {} - if need_to_add_context: - kwargs[self.pass_context_arg_name] = request.context - validation = scope_validate_func( - required_scopes, token_scopes, **kwargs) - if not validation: - raise OAuthScopeProblem( - description='Provided token doesn\'t have the required scope', - required_scopes=required_scopes, - token_scopes=token_scopes - ) - - return token_info - return wrapper - - @classmethod - def verify_security(cls, auth_funcs, function): - @functools.wraps(function) - def wrapper(request): - token_info = cls.no_value - errors = [] - for func in auth_funcs: - try: - token_info = func(request) - if token_info is not cls.no_value: - break - except Exception as err: - errors.append(err) - - if token_info is cls.no_value: - if errors != []: - cls._raise_most_specific(errors) - else: - logger.info("... No auth provided. Aborting with 401.") - raise OAuthProblem( - description='No authorization token provided') - - # Fallback to 'uid' for backward compatibility - request.context['user'] = token_info.get( - 'sub', token_info.get('uid')) - request.context['token_info'] = token_info - return function(request) - - return wrapper - - @staticmethod - def _raise_most_specific(exceptions: t.List[Exception]) -> None: - """Raises the most specific error from a list of exceptions by status code. - - The status codes are expected to be either in the `code` - or in the `status` attribute of the exceptions. - - The order is as follows: - - 403: valid credentials but not enough privileges - - 401: no or invalid credentials - - for other status codes, the smallest one is selected - - :param errors: List of exceptions. - :type errors: t.List[Exception] - """ - if not exceptions: - return - # We only use status code attributes from exceptions - # We use 600 as default because 599 is highest valid status code - status_to_exc = { - getattr(exc, 'code', getattr(exc, 'status', 600)): exc - for exc in exceptions - } - if 403 in status_to_exc: - raise status_to_exc[403] - elif 401 in status_to_exc: - raise status_to_exc[401] - else: - lowest_status_code = min(status_to_exc) - raise status_to_exc[lowest_status_code] - - @abc.abstractmethod - def get_token_info_remote(self, token_info_url): - """ - Return a function which will call `token_info_url` to retrieve token info. - - Returned function must accept oauth token in parameter. - It must return a token_info dict in case of success, None otherwise. - - :param token_info_url: Url to get information about the token - :type token_info_url: str - :rtype: types.FunctionType - """ +""" +This module defines an abstract SecurityHandlerFactory which supports the creation of security +handlers for operations. +""" + +import abc +import base64 +import functools +import http.cookies +import logging +import os +import textwrap +import typing as t + +from ..decorators.parameter import inspect_function_arguments +from ..exceptions import ( + FiretailException, + OAuthProblem, + OAuthResponseProblem, + OAuthScopeProblem, +) +from ..utils import get_function_from_name + +logger = logging.getLogger('firetail.api.security') + + +class AbstractSecurityHandlerFactory(abc.ABC): + """ + get_*_func -> _get_function -> get_function_from_name (name=security function defined in spec) + (if url defined instead of a function -> get_token_info_remote) + + std security functions: security_{passthrough,deny} + + verify_* -> returns a security wrapper around the security function + check_* -> returns a function tasked with doing auth for use inside the verify wrapper + check helpers (used outside wrappers): _need_to_add_context_or_scopes + the security function + + verify helpers (used inside wrappers): get_auth_header_value, get_cookie_value + """ + no_value = object() + required_scopes_kw = 'required_scopes' + + def __init__(self, pass_context_arg_name): + self.pass_context_arg_name = pass_context_arg_name + + @staticmethod + def _get_function(security_definition, security_definition_key, environ_key, default=None): + """ + Return function by getting its name from security_definition or environment variable + """ + func = security_definition.get(security_definition_key) or os.environ.get(environ_key) + if func: + return get_function_from_name(func) + return default + + def get_tokeninfo_func(self, security_definition: dict) -> t.Optional[t.Callable]: + """ + :type security_definition: dict + + >>> get_tokeninfo_url({'x-tokenInfoFunc': 'foo.bar'}) + '' + """ + token_info_func = self._get_function(security_definition, "x-tokenInfoFunc", 'TOKENINFO_FUNC') + if token_info_func: + return token_info_func + + token_info_url = (security_definition.get('x-tokenInfoUrl') or + os.environ.get('TOKENINFO_URL')) + if token_info_url: + return self.get_token_info_remote(token_info_url) + + return None + + @classmethod + def get_scope_validate_func(cls, security_definition): + """ + :type security_definition: dict + :rtype: function + + >>> get_scope_validate_func({'x-scopeValidateFunc': 'foo.bar'}) + '' + """ + return cls._get_function(security_definition, "x-scopeValidateFunc", 'SCOPEVALIDATE_FUNC', cls.validate_scope) + + @classmethod + def get_basicinfo_func(cls, security_definition): + """ + :type security_definition: dict + :rtype: function + + >>> get_basicinfo_func({'x-basicInfoFunc': 'foo.bar'}) + '' + """ + return cls._get_function(security_definition, "x-basicInfoFunc", 'BASICINFO_FUNC') + + @classmethod + def get_apikeyinfo_func(cls, security_definition): + """ + :type security_definition: dict + :rtype: function + + >>> get_apikeyinfo_func({'x-apikeyInfoFunc': 'foo.bar'}) + '' + """ + return cls._get_function(security_definition, "x-apikeyInfoFunc", 'APIKEYINFO_FUNC') + + @classmethod + def get_bearerinfo_func(cls, security_definition): + """ + :type security_definition: dict + :rtype: function + + >>> get_bearerinfo_func({'x-bearerInfoFunc': 'foo.bar'}) + '' + """ + return cls._get_function(security_definition, "x-bearerInfoFunc", 'BEARERINFO_FUNC') + + @staticmethod + def security_passthrough(function): + """ + :type function: types.FunctionType + :rtype: types.FunctionType + """ + return function + + @staticmethod + def security_deny(function): + """ + :type function: types.FunctionType + :rtype: types.FunctionType + """ + + def deny(*args, **kwargs): + raise FiretailException("Error in security definitions") + + return deny + + @staticmethod + def validate_scope(required_scopes, token_scopes): + """ + :param required_scopes: Scopes required to access operation + :param token_scopes: Scopes granted by authorization server + :rtype: bool + """ + required_scopes = set(required_scopes) + if isinstance(token_scopes, list): + token_scopes = set(token_scopes) + else: + token_scopes = set(token_scopes.split()) + logger.debug("... Scopes required: %s", required_scopes) + logger.debug("... Token scopes: %s", token_scopes) + if not required_scopes <= token_scopes: + logger.info(textwrap.dedent(""" + ... Token scopes (%s) do not match the scopes necessary to call endpoint (%s). + Aborting with 403.""").replace('\n', ''), + token_scopes, required_scopes) + return False + return True + + @staticmethod + def get_auth_header_value(request): + """ + Called inside security wrapper functions + + Return Authorization type and value if any. + If not Authorization, return (None, None) + Raise OAuthProblem for invalid Authorization header + """ + authorization = request.headers.get('Authorization') + if not authorization: + return None, None + + try: + auth_type, value = authorization.split(None, 1) + except ValueError: + raise OAuthProblem(description='Invalid authorization header') + return auth_type.lower(), value + + def verify_oauth(self, token_info_func, scope_validate_func, required_scopes): + check_oauth_func = self.check_oauth_func(token_info_func, scope_validate_func) + + def wrapper(request): + auth_type, token = self.get_auth_header_value(request) + if auth_type != 'bearer': + return self.no_value + + return check_oauth_func(request, token, required_scopes=required_scopes) + + return wrapper + + def verify_basic(self, basic_info_func): + check_basic_info_func = self.check_basic_auth(basic_info_func) + + def wrapper(request): + auth_type, user_pass = self.get_auth_header_value(request) + if auth_type != 'basic': + return self.no_value + + try: + username, password = base64.b64decode(user_pass).decode('latin1').split(':', 1) + except Exception: + raise OAuthProblem(description='Invalid authorization header') + + return check_basic_info_func(request, username, password) + + return wrapper + + @staticmethod + def get_cookie_value(cookies, name): + ''' + Called inside security wrapper functions + + Returns cookie value by its name. None if no such value. + :param cookies: str: cookies raw data + :param name: str: cookies key + ''' + cookie_parser = http.cookies.SimpleCookie() + cookie_parser.load(str(cookies)) + try: + return cookie_parser[name].value + except KeyError: + return None + + def verify_api_key(self, api_key_info_func, loc, name): + check_api_key_func = self.check_api_key(api_key_info_func) + + def wrapper(request): + + def _immutable_pop(_dict, key): + """ + Pops the key from an immutable dict and returns the value that was popped, + and a new immutable dict without the popped key. + """ + cls = type(_dict) + try: + _dict = _dict.to_dict(flat=False) + return _dict.pop(key)[0], cls(_dict) + except AttributeError: + _dict = dict(_dict.items()) + return _dict.pop(key), cls(_dict) + + if loc == 'query': + try: + api_key, request.query = _immutable_pop(request.query, name) + except KeyError: + api_key = None + elif loc == 'header': + api_key = request.headers.get(name) + elif loc == 'cookie': + cookie_list = request.headers.get('Cookie') + api_key = self.get_cookie_value(cookie_list, name) + else: + return self.no_value + + if api_key is None: + return self.no_value + + return check_api_key_func(request, api_key) + + return wrapper + + def verify_bearer(self, token_info_func): + """ + :param token_info_func: types.FunctionType + :rtype: types.FunctionType + """ + check_bearer_func = self.check_bearer_token(token_info_func) + + def wrapper(request): + auth_type, token = self.get_auth_header_value(request) + if auth_type != 'bearer': + return self.no_value + return check_bearer_func(request, token) + + return wrapper + + def verify_multiple_schemes(self, schemes): + """ + Verifies multiple authentication schemes in AND fashion. + If any scheme fails, the entire authentication fails. + + :param schemes: mapping scheme_name to auth function + :type schemes: dict + :rtype: types.FunctionType + """ + + def wrapper(request): + token_info = {} + for scheme_name, func in schemes.items(): + result = func(request) + if result is self.no_value: + return self.no_value + token_info[scheme_name] = result + + return token_info + + return wrapper + + @staticmethod + def verify_none(): + """ + :rtype: types.FunctionType + """ + + def wrapper(request): + return {} + + return wrapper + + def _need_to_add_context_or_scopes(self, func): + arguments, has_kwargs = inspect_function_arguments(func) + need_context = self.pass_context_arg_name and (has_kwargs or self.pass_context_arg_name in arguments) + need_required_scopes = has_kwargs or self.required_scopes_kw in arguments + return need_context, need_required_scopes + + def _generic_check(self, func, exception_msg): + need_to_add_context, need_to_add_required_scopes = self._need_to_add_context_or_scopes(func) + + def wrapper(request, *args, required_scopes=None): + kwargs = {} + if need_to_add_context: + kwargs[self.pass_context_arg_name] = request.context + if need_to_add_required_scopes: + kwargs[self.required_scopes_kw] = required_scopes + token_info = func(*args, **kwargs) + if token_info is self.no_value: + return self.no_value + if token_info is None: + raise OAuthResponseProblem(description=exception_msg, token_response=None) + return token_info + + return wrapper + + def check_bearer_token(self, token_info_func): + return self._generic_check(token_info_func, 'Provided token is not valid') + + def check_basic_auth(self, basic_info_func): + return self._generic_check(basic_info_func, 'Provided authorization is not valid') + + def check_api_key(self, api_key_info_func): + return self._generic_check(api_key_info_func, 'Provided apikey is not valid') + + def check_oauth_func(self, token_info_func, scope_validate_func): + get_token_info = self._generic_check(token_info_func, 'Provided token is not valid') + need_to_add_context, _ = self._need_to_add_context_or_scopes(scope_validate_func) + + def wrapper(request, token, required_scopes): + token_info = get_token_info(request, token, required_scopes=required_scopes) + + # Fallback to 'scopes' for backward compatibility + token_scopes = token_info.get('scope', token_info.get('scopes', '')) + + kwargs = {} + if need_to_add_context: + kwargs[self.pass_context_arg_name] = request.context + validation = scope_validate_func(required_scopes, token_scopes, **kwargs) + if not validation: + raise OAuthScopeProblem( + description='Provided token doesn\'t have the required scope', + required_scopes=required_scopes, + token_scopes=token_scopes + ) + + return token_info + return wrapper + + @classmethod + def verify_security(cls, auth_funcs, function): + @functools.wraps(function) + def wrapper(request): + token_info = cls.no_value + errors = [] + for func in auth_funcs: + try: + token_info = func(request) + if token_info is not cls.no_value: + break + except Exception as err: + errors.append(err) + + else: + if errors != []: + cls._raise_most_specific(errors) + else: + logger.info("... No auth provided. Aborting with 401.") + raise OAuthProblem(description='No authorization token provided') + + # Fallback to 'uid' for backward compatibility + request.context['user'] = token_info.get('sub', token_info.get('uid')) + request.context['token_info'] = token_info + return function(request) + + return wrapper + + @staticmethod + def _raise_most_specific(exceptions: t.List[Exception]) -> None: + """Raises the most specific error from a list of exceptions by status code. + + The status codes are expected to be either in the `code` + or in the `status` attribute of the exceptions. + + The order is as follows: + - 403: valid credentials but not enough privileges + - 401: no or invalid credentials + - for other status codes, the smallest one is selected + + :param errors: List of exceptions. + :type errors: t.List[Exception] + """ + if not exceptions: + return + # We only use status code attributes from exceptions + # We use 600 as default because 599 is highest valid status code + status_to_exc = { + getattr(exc, 'code', getattr(exc, 'status', 600)): exc + for exc in exceptions + } + if 403 in status_to_exc: + raise status_to_exc[403] + elif 401 in status_to_exc: + raise status_to_exc[401] + else: + lowest_status_code = min(status_to_exc) + raise status_to_exc[lowest_status_code] + + @abc.abstractmethod + def get_token_info_remote(self, token_info_url): + """ + Return a function which will call `token_info_url` to retrieve token info. + + Returned function must accept oauth token in parameter. + It must return a token_info dict in case of success, None otherwise. + + :param token_info_url: Url to get information about the token + :type token_info_url: str + :rtype: types.FunctionType + """ diff --git a/firetail/sender.py b/firetail/sender.py index b6fa342..a94cbd0 100644 --- a/firetail/sender.py +++ b/firetail/sender.py @@ -33,7 +33,6 @@ def backup_logs(logs, logger): class FiretailSender: def __init__(self, token, - api_key, url, logs_drain_timeout=5, debug=False, @@ -42,7 +41,6 @@ def __init__(self, number_of_retries=4, retry_timeout=2): self.token = token - self.api_key = api_key self.url = url self.logs_drain_timeout = logs_drain_timeout self.stdout_logger = get_stdout_logger(debug) @@ -116,9 +114,10 @@ def _flush_queue(self): self.number_of_retries = self.number_of_retries should_backup_to_disk = True - headers = {"Content-type": "text/plain", - 'x-api-key': self.api_key, - 'x-ps-api-key': self.token} + headers = { + "Content-type": "application/x-ndjson", + 'x-ft-api-key': self.token + } for current_try in range(self.number_of_retries): should_retry = False diff --git a/firetail/spec.py b/firetail/spec.py index d78cbc9..9e114a9 100644 --- a/firetail/spec.py +++ b/firetail/spec.py @@ -1,299 +1,299 @@ -""" -This module defines Python interfaces for OpenAPI specifications. -""" - -import abc -import copy -import json -import pathlib -import pkgutil -from collections.abc import Mapping -from urllib.parse import urlsplit - -import jinja2 -import jsonschema -import yaml -from jsonschema import Draft4Validator -from jsonschema.validators import extend as extend_validator - -from .exceptions import InvalidSpecification -from .json_schema import NullableTypeValidator, resolve_refs -from .operations import OpenAPIOperation, Swagger2Operation -from .utils import deep_get - -validate_properties = Draft4Validator.VALIDATORS["properties"] - - -def create_spec_validator(spec: dict) -> Draft4Validator: - """Create a Validator to validate an OpenAPI spec against the OpenAPI schema. - - :param spec: specification to validate - """ - # Create an instance validator, which validates defaults against the spec itself instead of - # against the OpenAPI schema. - InstanceValidator = extend_validator(Draft4Validator, {'type': NullableTypeValidator}) - instance_validator = InstanceValidator(spec) - - def validate_defaults(validator, properties, instance, schema): - """Validation function to validate the `properties` subschema, enforcing each default - value validates against the schema in which it resides. - """ - valid = True - for error in validate_properties(validator, properties, instance, schema): - valid = False - yield error - - # Validate default only when the subschema has validated successfully - if not valid: - return - if isinstance(instance, dict) and 'default' in instance: - for error in instance_validator.iter_errors(instance['default'], instance): - yield error - - SpecValidator = extend_validator(Draft4Validator, {"properties": validate_defaults}) - return SpecValidator - - -NO_SPEC_VERSION_ERR_MSG = """Unable to get the spec version. -You are missing either '"swagger": "2.0"' or '"openapi": "3.0.0"' -from the top level of your spec.""" - - -def canonical_base_path(base_path): - """ - Make given "basePath" a canonical base URL which can be prepended to paths starting with "/". - """ - return base_path.rstrip('/') - - -class Specification(Mapping): - - def __init__(self, raw_spec): - self._raw_spec = copy.deepcopy(raw_spec) - self._set_defaults(raw_spec) - self._validate_spec(raw_spec) - self._spec = resolve_refs(raw_spec) - - @classmethod - @abc.abstractmethod - def _set_defaults(cls, spec): - """ set some default values in the spec - """ - - @classmethod - def _validate_spec(cls, spec): - """ validate spec against schema - """ - try: - OpenApiValidator = create_spec_validator(spec) - validator = OpenApiValidator(cls.openapi_schema) - validator.validate(spec) - except jsonschema.exceptions.ValidationError as e: - raise InvalidSpecification.create_from(e) - - def get_path_params(self, path): - return deep_get(self._spec, ["paths", path]).get("parameters", []) - - def get_operation(self, path, method): - return deep_get(self._spec, ["paths", path, method]) - - @property - def raw(self): - return self._raw_spec - - @property - def version(self): - return self._get_spec_version(self._spec) - - @property - def security(self): - return self._spec.get('security') - - def __getitem__(self, k): - return self._spec[k] - - def __iter__(self): - return self._spec.__iter__() - - def __len__(self): - return self._spec.__len__() - - @staticmethod - def _load_spec_from_file(arguments, specification): - """ - Loads a YAML specification file, optionally rendering it with Jinja2. - - :param arguments: passed to Jinja2 renderer - :param specification: path to specification - """ - arguments = arguments or {} - - with specification.open(mode='rb') as openapi_yaml: - contents = openapi_yaml.read() - try: - openapi_template = contents.decode() - except UnicodeDecodeError: - openapi_template = contents.decode('utf-8', 'replace') - - openapi_string = jinja2.Template(openapi_template).render(**arguments) - return yaml.safe_load(openapi_string) - - @classmethod - def from_file(cls, spec, arguments=None): - """ - Takes in a path to a YAML file, and returns a Specification - """ - specification_path = pathlib.Path(spec) - spec = cls._load_spec_from_file(arguments, specification_path) - return cls.from_dict(spec) - - @staticmethod - def _get_spec_version(spec): - try: - version_string = spec.get('openapi') or spec.get('swagger') - except AttributeError: - raise InvalidSpecification(NO_SPEC_VERSION_ERR_MSG) - if version_string is None: - raise InvalidSpecification(NO_SPEC_VERSION_ERR_MSG) - try: - version_tuple = tuple(map(int, version_string.split("."))) - except TypeError: - err = ('Unable to convert version string to semantic version tuple: ' - '{version_string}.') - err = err.format(version_string=version_string) - raise InvalidSpecification(err) - return version_tuple - - @classmethod - def from_dict(cls, spec): - """ - Takes in a dictionary, and returns a Specification - """ - def enforce_string_keys(obj): - # YAML supports integer keys, but JSON does not - if isinstance(obj, dict): - return { - str(k): enforce_string_keys(v) - for k, v - in obj.items() - } - return obj - - spec = enforce_string_keys(spec) - version = cls._get_spec_version(spec) - if version < (3, 0, 0): - return Swagger2Specification(spec) - return OpenAPISpecification(spec) - - def clone(self): - return type(self)(copy.deepcopy(self._raw_spec)) - - @classmethod - def load(cls, spec, arguments=None): - if not isinstance(spec, dict): - return cls.from_file(spec, arguments=arguments) - return cls.from_dict(spec) - - def with_base_path(self, base_path): - new_spec = self.clone() - new_spec.base_path = base_path - return new_spec - - -class Swagger2Specification(Specification): - """Python interface for a Swagger 2 specification.""" - - yaml_name = 'swagger.yaml' - operation_cls = Swagger2Operation - - openapi_schema = json.loads( - pkgutil.get_data('firetail', 'resources/schemas/v2.0/schema.json') - ) - - @classmethod - def _set_defaults(cls, spec): - spec.setdefault('produces', []) - spec.setdefault('consumes', ['application/json']) - spec.setdefault('definitions', {}) - spec.setdefault('parameters', {}) - spec.setdefault('responses', {}) - - @property - def produces(self): - return self._spec['produces'] - - @property - def consumes(self): - return self._spec['consumes'] - - @property - def definitions(self): - return self._spec['definitions'] - - @property - def parameter_definitions(self): - return self._spec['parameters'] - - @property - def response_definitions(self): - return self._spec['responses'] - - @property - def security_definitions(self): - return self._spec.get('securityDefinitions', {}) - - @property - def base_path(self): - return canonical_base_path(self._spec.get('basePath', '')) - - @base_path.setter - def base_path(self, base_path): - base_path = canonical_base_path(base_path) - self._raw_spec['basePath'] = base_path - self._spec['basePath'] = base_path - - -class OpenAPISpecification(Specification): - """Python interface for an OpenAPI 3 specification.""" - - yaml_name = 'openapi.yaml' - operation_cls = OpenAPIOperation - - openapi_schema = json.loads( - pkgutil.get_data('firetail', 'resources/schemas/v3.0/schema.json') - ) - - @classmethod - def _set_defaults(cls, spec): - spec.setdefault('components', {}) - - @property - def security_definitions(self): - return self._spec['components'].get('securitySchemes', {}) - - @property - def components(self): - return self._spec['components'] - - @property - def base_path(self): - servers = self._spec.get('servers', []) - try: - # assume we're the first server in list - server = copy.deepcopy(servers[0]) - server_vars = server.pop("variables", {}) - server['url'] = server['url'].format( - **{k: v['default'] for k, v - in server_vars.items()} - ) - base_path = urlsplit(server['url']).path - except IndexError: - base_path = '' - return canonical_base_path(base_path) - - @base_path.setter - def base_path(self, base_path): - base_path = canonical_base_path(base_path) - user_servers = [{'url': base_path}] - self._raw_spec['servers'] = user_servers - self._spec['servers'] = user_servers +""" +This module defines Python interfaces for OpenAPI specifications. +""" + +import abc +import copy +import json +import pathlib +import pkgutil +from collections.abc import Mapping +from urllib.parse import urlsplit + +import jinja2 +import jsonschema +import yaml +from jsonschema import Draft4Validator +from jsonschema.validators import extend as extend_validator + +from .exceptions import InvalidSpecification +from .json_schema import NullableTypeValidator, resolve_refs +from .operations import OpenAPIOperation, Swagger2Operation +from .utils import deep_get + +validate_properties = Draft4Validator.VALIDATORS["properties"] + + +def create_spec_validator(spec: dict) -> Draft4Validator: + """Create a Validator to validate an OpenAPI spec against the OpenAPI schema. + + :param spec: specification to validate + """ + # Create an instance validator, which validates defaults against the spec itself instead of + # against the OpenAPI schema. + InstanceValidator = extend_validator(Draft4Validator, {'type': NullableTypeValidator}) + instance_validator = InstanceValidator(spec) + + def validate_defaults(validator, properties, instance, schema): + """Validation function to validate the `properties` subschema, enforcing each default + value validates against the schema in which it resides. + """ + valid = True + for error in validate_properties(validator, properties, instance, schema): + valid = False + yield error + + # Validate default only when the subschema has validated successfully + if not valid: + return + if isinstance(instance, dict) and 'default' in instance: + for error in instance_validator.iter_errors(instance['default'], instance): + yield error + + SpecValidator = extend_validator(Draft4Validator, {"properties": validate_defaults}) + return SpecValidator + + +NO_SPEC_VERSION_ERR_MSG = """Unable to get the spec version. +You are missing either '"swagger": "2.0"' or '"openapi": "3.0.0"' +from the top level of your spec.""" + + +def canonical_base_path(base_path): + """ + Make given "basePath" a canonical base URL which can be prepended to paths starting with "/". + """ + return base_path.rstrip('/') + + +class Specification(Mapping): + + def __init__(self, raw_spec): + self._raw_spec = copy.deepcopy(raw_spec) + self._set_defaults(raw_spec) + self._validate_spec(raw_spec) + self._spec = resolve_refs(raw_spec) + + @classmethod + @abc.abstractmethod + def _set_defaults(cls, spec): + """ set some default values in the spec + """ + + @classmethod + def _validate_spec(cls, spec): + """ validate spec against schema + """ + try: + OpenApiValidator = create_spec_validator(spec) + validator = OpenApiValidator(cls.openapi_schema) + validator.validate(spec) + except jsonschema.exceptions.ValidationError as e: + raise InvalidSpecification.create_from(e) + + def get_path_params(self, path): + return deep_get(self._spec, ["paths", path]).get("parameters", []) + + def get_operation(self, path, method): + return deep_get(self._spec, ["paths", path, method]) + + @property + def raw(self): + return self._raw_spec + + @property + def version(self): + return self._get_spec_version(self._spec) + + @property + def security(self): + return self._spec.get('security') + + def __getitem__(self, k): + return self._spec[k] + + def __iter__(self): + return self._spec.__iter__() + + def __len__(self): + return self._spec.__len__() + + @staticmethod + def _load_spec_from_file(arguments, specification): + """ + Loads a YAML specification file, optionally rendering it with Jinja2. + + :param arguments: passed to Jinja2 renderer + :param specification: path to specification + """ + arguments = arguments or {} + + with specification.open(mode='rb') as openapi_yaml: + contents = openapi_yaml.read() + try: + openapi_template = contents.decode() + except UnicodeDecodeError: + openapi_template = contents.decode('utf-8', 'replace') + + openapi_string = jinja2.Template(openapi_template).render(**arguments) + return yaml.safe_load(openapi_string) + + @classmethod + def from_file(cls, spec, arguments=None): + """ + Takes in a path to a YAML file, and returns a Specification + """ + specification_path = pathlib.Path(spec) + spec = cls._load_spec_from_file(arguments, specification_path) + return cls.from_dict(spec) + + @staticmethod + def _get_spec_version(spec): + try: + version_string = spec.get('openapi') or spec.get('swagger') + except AttributeError: + raise InvalidSpecification(NO_SPEC_VERSION_ERR_MSG) + if version_string is None: + raise InvalidSpecification(NO_SPEC_VERSION_ERR_MSG) + try: + version_tuple = tuple(map(int, version_string.split("."))) + except TypeError: + err = ('Unable to convert version string to semantic version tuple: ' + '{version_string}.') + err = err.format(version_string=version_string) + raise InvalidSpecification(err) + return version_tuple + + @classmethod + def from_dict(cls, spec): + """ + Takes in a dictionary, and returns a Specification + """ + def enforce_string_keys(obj): + # YAML supports integer keys, but JSON does not + if isinstance(obj, dict): + return { + str(k): enforce_string_keys(v) + for k, v + in obj.items() + } + return obj + + spec = enforce_string_keys(spec) + version = cls._get_spec_version(spec) + if version < (3, 0, 0): + return Swagger2Specification(spec) + return OpenAPISpecification(spec) + + def clone(self): + return type(self)(copy.deepcopy(self._raw_spec)) + + @classmethod + def load(cls, spec, arguments=None): + if not isinstance(spec, dict): + return cls.from_file(spec, arguments=arguments) + return cls.from_dict(spec) + + def with_base_path(self, base_path): + new_spec = self.clone() + new_spec.base_path = base_path + return new_spec + + +class Swagger2Specification(Specification): + """Python interface for a Swagger 2 specification.""" + + yaml_name = 'swagger.yaml' + operation_cls = Swagger2Operation + + openapi_schema = json.loads( + pkgutil.get_data('firetail', 'resources/schemas/v2.0/schema.json') + ) + + @classmethod + def _set_defaults(cls, spec): + spec.setdefault('produces', []) + spec.setdefault('consumes', ['application/json']) + spec.setdefault('definitions', {}) + spec.setdefault('parameters', {}) + spec.setdefault('responses', {}) + + @property + def produces(self): + return self._spec['produces'] + + @property + def consumes(self): + return self._spec['consumes'] + + @property + def definitions(self): + return self._spec['definitions'] + + @property + def parameter_definitions(self): + return self._spec['parameters'] + + @property + def response_definitions(self): + return self._spec['responses'] + + @property + def security_definitions(self): + return self._spec.get('securityDefinitions', {}) + + @property + def base_path(self): + return canonical_base_path(self._spec.get('basePath', '')) + + @base_path.setter + def base_path(self, base_path): + base_path = canonical_base_path(base_path) + self._raw_spec['basePath'] = base_path + self._spec['basePath'] = base_path + + +class OpenAPISpecification(Specification): + """Python interface for an OpenAPI 3 specification.""" + + yaml_name = 'openapi.yaml' + operation_cls = OpenAPIOperation + + openapi_schema = json.loads( + pkgutil.get_data('firetail', 'resources/schemas/v3.0/schema.json') + ) + + @classmethod + def _set_defaults(cls, spec): + spec.setdefault('components', {}) + + @property + def security_definitions(self): + return self._spec['components'].get('securitySchemes', {}) + + @property + def components(self): + return self._spec['components'] + + @property + def base_path(self): + servers = self._spec.get('servers', []) + try: + # assume we're the first server in list + server = copy.deepcopy(servers[0]) + server_vars = server.pop("variables", {}) + server['url'] = server['url'].format( + **{k: v['default'] for k, v + in server_vars.items()} + ) + base_path = urlsplit(server['url']).path + except IndexError: + base_path = '' + return canonical_base_path(base_path) + + @base_path.setter + def base_path(self, base_path): + base_path = canonical_base_path(base_path) + user_servers = [{'url': base_path}] + self._raw_spec['servers'] = user_servers + self._spec['servers'] = user_servers diff --git a/firetail/utils.py b/firetail/utils.py index f9b3483..c7c70de 100644 --- a/firetail/utils.py +++ b/firetail/utils.py @@ -1,262 +1,262 @@ -""" -This module provides general utility functions used within Firetail. -""" - -import asyncio -import functools -import importlib - -import yaml - - -def boolean(s): - """ - Convert JSON/Swagger boolean value to Python, raise ValueError otherwise - - >>> boolean('true') - True - - >>> boolean('false') - False - """ - if isinstance(s, bool): - return s - elif not hasattr(s, 'lower'): - raise ValueError('Invalid boolean value') - elif s.lower() == 'true': - return True - elif s.lower() == 'false': - return False - else: - raise ValueError('Invalid boolean value') - - -# https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#data-types -TYPE_MAP = {'integer': int, - 'number': float, - 'string': str, - 'boolean': boolean, - 'array': list, - 'object': dict} # map of swagger types to python types - - -def make_type(value, _type): - type_func = TYPE_MAP[_type] # convert value to right type - return type_func(value) - - -def deep_merge(a, b): - """ merges b into a - in case of conflict the value from b is used - """ - for key in b: - if key in a: - if isinstance(a[key], dict) and isinstance(b[key], dict): - deep_merge(a[key], b[key]) - elif a[key] == b[key]: - pass - else: - # b overwrites a - a[key] = b[key] - else: - a[key] = b[key] - return a - - -def deep_getattr(obj, attr): - """ - Recurses through an attribute chain to get the ultimate value. - """ - - attrs = attr.split('.') - - return functools.reduce(getattr, attrs, obj) - - -def deep_get(obj, keys): - """ - Recurses through a nested object get a leaf value. - - There are cases where the use of inheritance or polymorphism-- the use of allOf or - oneOf keywords-- will cause the obj to be a list. In this case the keys will - contain one or more strings containing integers. - - :type obj: list or dict - :type keys: list of strings - """ - if not keys: - return obj - - if isinstance(obj, list): - return deep_get(obj[int(keys[0])], keys[1:]) - else: - return deep_get(obj[keys[0]], keys[1:]) - - -def get_function_from_name(function_name): - """ - Tries to get function by fully qualified name (e.g. "mymodule.myobj.myfunc") - - :type function_name: str - """ - if function_name is None: - raise ValueError("Empty function name") - - if '.' in function_name: - module_name, attr_path = function_name.rsplit('.', 1) - else: - module_name = '' - attr_path = function_name - - module = None - last_import_error = None - - while not module: - try: - module = importlib.import_module(module_name) - except ImportError as import_error: - last_import_error = import_error - if '.' in module_name: - module_name, attr_path1 = module_name.rsplit('.', 1) - attr_path = f'{attr_path1}.{attr_path}' - else: - raise - try: - function = deep_getattr(module, attr_path) - except AttributeError: - if last_import_error: - raise last_import_error - else: - raise - return function - - -def is_json_mimetype(mimetype): - """ - :type mimetype: str - :rtype: bool - """ - maintype, subtype = mimetype.split('/') # type: str, str - return maintype == 'application' and (subtype == 'json' or subtype.endswith('+json')) - - -def all_json(mimetypes): - """ - Returns True if all mimetypes are serialized with json - - :type mimetypes: list - :rtype: bool - - >>> all_json(['application/json']) - True - >>> all_json(['application/x.custom+json']) - True - >>> all_json([]) - True - >>> all_json(['application/xml']) - False - >>> all_json(['text/json']) - False - >>> all_json(['application/json', 'other/type']) - False - >>> all_json(['application/json', 'application/x.custom+json']) - True - """ - return all(is_json_mimetype(mimetype) for mimetype in mimetypes) - - -def is_nullable(param_def): - return ( - param_def.get('schema', param_def).get('nullable', False) or - param_def.get('x-nullable', False) # swagger2 - ) - - -def is_null(value): - if hasattr(value, 'strip') and value.strip() in ['null', 'None']: - return True - - if value is None: - return True - - return False - - -def has_coroutine(function, api=None): - """ - Checks if function is a coroutine. - If ``function`` is a decorator (has a ``__wrapped__`` attribute) - this function will also look at the wrapped function. - """ - def iscorofunc(func): - iscorofunc = asyncio.iscoroutinefunction(func) - while not iscorofunc and hasattr(func, '__wrapped__'): - func = func.__wrapped__ - iscorofunc = asyncio.iscoroutinefunction(func) - return iscorofunc - - if api is None: - return iscorofunc(function) - - else: - return any( - iscorofunc(func) for func in ( - function, api.get_request, api.get_response - ) - ) - - -def yamldumper(openapi): - """ - Returns a nicely-formatted yaml spec. - :param openapi: a spec dictionary. - :return: a nicely-formatted, serialized yaml spec. - """ - def should_use_block(value): - char_list = ( - "\u000a" # line feed - "\u000d" # carriage return - "\u001c" # file separator - "\u001d" # group separator - "\u001e" # record separator - "\u0085" # next line - "\u2028" # line separator - "\u2029" # paragraph separator - ) - for c in char_list: - if c in value: - return True - return False - - def my_represent_scalar(self, tag, value, style=None): - if should_use_block(value): - style = '|' - else: - style = self.default_style - - node = yaml.representer.ScalarNode(tag, value, style=style) - if self.alias_key is not None: - self.represented_objects[self.alias_key] = node - return node - - class NoAnchorDumper(yaml.dumper.SafeDumper): - """A yaml Dumper that does not replace duplicate entries - with yaml anchors. - """ - - def ignore_aliases(self, *args): - return True - - # Dump long lines as "|". - yaml.representer.SafeRepresenter.represent_scalar = my_represent_scalar - - return yaml.dump(openapi, allow_unicode=True, Dumper=NoAnchorDumper) - - -def not_installed_error(exc): # pragma: no cover - """Raises the ImportError when the module/object is actually called.""" - - def _required_lib(exc, *args, **kwargs): - raise exc - - return functools.partial(_required_lib, exc) +""" +This module provides general utility functions used within Firetail. +""" + +import asyncio +import functools +import importlib + +import yaml + + +def boolean(s): + """ + Convert JSON/Swagger boolean value to Python, raise ValueError otherwise + + >>> boolean('true') + True + + >>> boolean('false') + False + """ + if isinstance(s, bool): + return s + elif not hasattr(s, 'lower'): + raise ValueError('Invalid boolean value') + elif s.lower() == 'true': + return True + elif s.lower() == 'false': + return False + else: + raise ValueError('Invalid boolean value') + + +# https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#data-types +TYPE_MAP = {'integer': int, + 'number': float, + 'string': str, + 'boolean': boolean, + 'array': list, + 'object': dict} # map of swagger types to python types + + +def make_type(value, _type): + type_func = TYPE_MAP[_type] # convert value to right type + return type_func(value) + + +def deep_merge(a, b): + """ merges b into a + in case of conflict the value from b is used + """ + for key in b: + if key in a: + if isinstance(a[key], dict) and isinstance(b[key], dict): + deep_merge(a[key], b[key]) + elif a[key] == b[key]: + pass + else: + # b overwrites a + a[key] = b[key] + else: + a[key] = b[key] + return a + + +def deep_getattr(obj, attr): + """ + Recurses through an attribute chain to get the ultimate value. + """ + + attrs = attr.split('.') + + return functools.reduce(getattr, attrs, obj) + + +def deep_get(obj, keys): + """ + Recurses through a nested object get a leaf value. + + There are cases where the use of inheritance or polymorphism-- the use of allOf or + oneOf keywords-- will cause the obj to be a list. In this case the keys will + contain one or more strings containing integers. + + :type obj: list or dict + :type keys: list of strings + """ + if not keys: + return obj + + if isinstance(obj, list): + return deep_get(obj[int(keys[0])], keys[1:]) + else: + return deep_get(obj[keys[0]], keys[1:]) + + +def get_function_from_name(function_name): + """ + Tries to get function by fully qualified name (e.g. "mymodule.myobj.myfunc") + + :type function_name: str + """ + if function_name is None: + raise ValueError("Empty function name") + + if '.' in function_name: + module_name, attr_path = function_name.rsplit('.', 1) + else: + module_name = '' + attr_path = function_name + + module = None + last_import_error = None + + while not module: + try: + module = importlib.import_module(module_name) + except ImportError as import_error: + last_import_error = import_error + if '.' in module_name: + module_name, attr_path1 = module_name.rsplit('.', 1) + attr_path = f'{attr_path1}.{attr_path}' + else: + raise + try: + function = deep_getattr(module, attr_path) + except AttributeError: + if last_import_error: + raise last_import_error + else: + raise + return function + + +def is_json_mimetype(mimetype): + """ + :type mimetype: str + :rtype: bool + """ + maintype, subtype = mimetype.split('/') # type: str, str + return maintype == 'application' and (subtype == 'json' or subtype.endswith('+json')) + + +def all_json(mimetypes): + """ + Returns True if all mimetypes are serialized with json + + :type mimetypes: list + :rtype: bool + + >>> all_json(['application/json']) + True + >>> all_json(['application/x.custom+json']) + True + >>> all_json([]) + True + >>> all_json(['application/xml']) + False + >>> all_json(['text/json']) + False + >>> all_json(['application/json', 'other/type']) + False + >>> all_json(['application/json', 'application/x.custom+json']) + True + """ + return all(is_json_mimetype(mimetype) for mimetype in mimetypes) + + +def is_nullable(param_def): + return ( + param_def.get('schema', param_def).get('nullable', False) or + param_def.get('x-nullable', False) # swagger2 + ) + + +def is_null(value): + if hasattr(value, 'strip') and value.strip() in ['null', 'None']: + return True + + if value is None: + return True + + return False + + +def has_coroutine(function, api=None): + """ + Checks if function is a coroutine. + If ``function`` is a decorator (has a ``__wrapped__`` attribute) + this function will also look at the wrapped function. + """ + def iscorofunc(func): + iscorofunc = asyncio.iscoroutinefunction(func) + while not iscorofunc and hasattr(func, '__wrapped__'): + func = func.__wrapped__ + iscorofunc = asyncio.iscoroutinefunction(func) + return iscorofunc + + if api is None: + return iscorofunc(function) + + else: + return any( + iscorofunc(func) for func in ( + function, api.get_request, api.get_response + ) + ) + + +def yamldumper(openapi): + """ + Returns a nicely-formatted yaml spec. + :param openapi: a spec dictionary. + :return: a nicely-formatted, serialized yaml spec. + """ + def should_use_block(value): + char_list = ( + "\u000a" # line feed + "\u000d" # carriage return + "\u001c" # file separator + "\u001d" # group separator + "\u001e" # record separator + "\u0085" # next line + "\u2028" # line separator + "\u2029" # paragraph separator + ) + for c in char_list: + if c in value: + return True + return False + + def my_represent_scalar(self, tag, value, style=None): + if should_use_block(value): + style = '|' + else: + style = self.default_style + + node = yaml.representer.ScalarNode(tag, value, style=style) + if self.alias_key is not None: + self.represented_objects[self.alias_key] = node + return node + + class NoAnchorDumper(yaml.dumper.SafeDumper): + """A yaml Dumper that does not replace duplicate entries + with yaml anchors. + """ + + def ignore_aliases(self, *args): + return True + + # Dump long lines as "|". + yaml.representer.SafeRepresenter.represent_scalar = my_represent_scalar + + return yaml.dump(openapi, allow_unicode=True, Dumper=NoAnchorDumper) + + +def not_installed_error(exc): # pragma: no cover + """Raises the ImportError when the module/object is actually called.""" + + def _required_lib(exc, *args, **kwargs): + raise exc + + return functools.partial(_required_lib, exc) diff --git a/setup.py b/setup.py index 97a4796..4305814 100644 --- a/setup.py +++ b/setup.py @@ -7,8 +7,7 @@ from setuptools import find_packages, setup from setuptools.command.test import test as TestCommand -__location__ = os.path.join(os.getcwd(), os.path.dirname( - inspect.getfile(inspect.currentframe()))) +__location__ = os.path.join(os.getcwd(), os.path.dirname(inspect.getfile(inspect.currentframe()))) def read_version(package): @@ -22,26 +21,29 @@ def read_version(package): install_requires = [ 'clickclick>=1.2,<21', - 'jsonschema>=2.5.1,<5', + 'jsonschema>=4.0.1,<5', 'PyYAML>=5.1,<7', 'PyJWT>=2.3.0', 'requests>=2.27,<3', 'inflection>=0.3.1,<0.6', - 'werkzeug>=2,<3', - 'importlib-metadata>=1 ; python_version<"3.8"', - 'packaging>=20', + 'werkzeug>=2.2.1,<3', 'starlette>=0.15,<1', ] swagger_ui_require = 'swagger-ui-bundle>=0.0.2,<0.1' flask_require = [ - 'flask>=2,<3', - 'a2wsgi>=1.1,<2', + 'flask[async]>=2.2,<3', + 'a2wsgi>=1.4,<2', +] + +aiohttp_require = [ + 'aiohttp>=2.3.10,<4', + 'aiohttp-jinja2>=0.14.0,<2', + 'MarkupSafe>=0.23', ] tests_require = [ - 'decorator>=5,<6', 'pytest>=6,<7', 'pytest-cov>=2,<3', 'testfixtures>=6,<7', @@ -49,6 +51,10 @@ def read_version(package): swagger_ui_require ] +tests_require.extend(aiohttp_require) +tests_require.append('pytest-aiohttp') +tests_require.append('aiohttp-remotes') + docs_require = [ 'sphinx-autoapi==1.8.1' ] @@ -92,11 +98,11 @@ def readme(): description='Firetail - API first applications with OpenAPI/Swagger and Flask', long_description=readme(), # long_description_content_type="text/x-rst", - author='Point Security Inc DBA FireTail (TM)', + author='FireTail International (TM)', url='https://github.com/FireTail-io/firetail-py-lib', keywords='openapi oai swagger rest api oauth flask microservice framework', license='LGPLv3', - setup_requires=['flake8'], + # setup_requires=['flake8'], python_requires=">=3.6", install_requires=install_requires + flask_require, tests_require=tests_require, diff --git a/tests/aiohttp/test_aiohttp_api_secure.py b/tests/aiohttp/test_aiohttp_api_secure.py new file mode 100644 index 0000000..db0075e --- /dev/null +++ b/tests/aiohttp/test_aiohttp_api_secure.py @@ -0,0 +1,175 @@ +import base64 +from unittest.mock import MagicMock + +import pytest +from firetail import AioHttpApp + + +class FakeAioHttpClientResponse: + def __init__(self, status_code, data): + """ + :type status_code: int + :type data: dict + """ + self.status = status_code + self.data = data + self.ok = status_code == 200 + + async def json(self): + return self.data + + +@pytest.fixture +def oauth_aiohttp_client(monkeypatch): + async def fake_get(url, params=None, headers=None, timeout=None): + """ + :type url: str + :type params: dict| None + """ + headers = headers or {} + assert url == "https://oauth.example/token_info" + token = headers.get('Authorization', 'invalid').split()[-1] + if token in ["100", "has_myscope"]: + return FakeAioHttpClientResponse(200, {"uid": "test-user", "scope": ["myscope"]}) + elif token in ["200", "has_wrongscope"]: + return FakeAioHttpClientResponse(200, {"uid": "test-user", "scope": ["wrongscope"]}) + elif token == "has_myscope_otherscope": + return FakeAioHttpClientResponse(200, {"uid": "test-user", "scope": ["myscope", "otherscope"]}) + elif token in ["300", "is_not_invalid"]: + return FakeAioHttpClientResponse(404, {}) + elif token == "has_scopes_in_scopes_with_s": + return FakeAioHttpClientResponse(200, {"uid": "test-user", "scopes": ["myscope", "otherscope"]}) + else: + raise AssertionError('Not supported test token ' + token) + + client_instance = MagicMock() + client_instance.get = fake_get + monkeypatch.setattr('aiohttp.ClientSession', MagicMock(return_value=client_instance)) + + +async def test_auth_all_paths(oauth_aiohttp_client, aiohttp_api_spec_dir, aiohttp_client): + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir, + debug=True, auth_all_paths=True) + app.add_api('swagger_secure.yaml') + + app_client = await aiohttp_client(app.app) + + get_inexistent_endpoint = await app_client.get( + '/v1.0/does-not-exist-valid-token', + headers={'Authorization': 'Bearer 100'} + ) + assert get_inexistent_endpoint.status == 404 + assert get_inexistent_endpoint.content_type == 'application/problem+json' + + get_inexistent_endpoint = await app_client.get( + '/v1.0/does-not-exist-no-token' + ) + assert get_inexistent_endpoint.status == 401 + assert get_inexistent_endpoint.content_type == 'application/problem+json' + + +@pytest.mark.parametrize('spec', ['swagger_secure.yaml', 'openapi_secure.yaml']) +async def test_secure_app(oauth_aiohttp_client, aiohttp_api_spec_dir, aiohttp_client, spec): + """ + Test common authentication method between Swagger 2 and OpenApi 3 + """ + app = AioHttpApp(__name__, port=5001, specification_dir=aiohttp_api_spec_dir, debug=True) + app.add_api(spec) + app_client = await aiohttp_client(app.app) + + response = await app_client.get('/v1.0/all_auth') + assert response.status == 401 + assert response.content_type == 'application/problem+json' + + response = await app_client.get('/v1.0/all_auth', headers={'Authorization': 'Bearer 100'}) + assert response.status == 200 + assert (await response.json()) == {"scope": ['myscope'], "uid": 'test-user'} + + response = await app_client.get('/v1.0/all_auth', headers={'authorization': 'Bearer 100'}) + assert response.status == 200, "Authorization header in lower case should be accepted" + assert (await response.json()) == {"scope": ['myscope'], "uid": 'test-user'} + + response = await app_client.get('/v1.0/all_auth', headers={'AUTHORIZATION': 'Bearer 100'}) + assert response.status == 200, "Authorization header in upper case should be accepted" + assert (await response.json()) == {"scope": ['myscope'], "uid": 'test-user'} + + basic_header = 'Basic ' + base64.b64encode(b'username:username').decode('ascii') + response = await app_client.get('/v1.0/all_auth', headers={'Authorization': basic_header}) + assert response.status == 200 + assert (await response.json()) == {"uid": 'username'} + + basic_header = 'Basic ' + base64.b64encode(b'username:wrong').decode('ascii') + response = await app_client.get('/v1.0/all_auth', headers={'Authorization': basic_header}) + assert response.status == 401, "Wrong password should trigger unauthorized" + assert response.content_type == 'application/problem+json' + + response = await app_client.get('/v1.0/all_auth', headers={'X-API-Key': '{"foo": "bar"}'}) + assert response.status == 200 + assert (await response.json()) == {"foo": "bar"} + + +async def test_bearer_secure(aiohttp_api_spec_dir, aiohttp_client): + """ + Test authentication method specific to OpenApi 3 + """ + app = AioHttpApp(__name__, port=5001, specification_dir=aiohttp_api_spec_dir, debug=True) + app.add_api('openapi_secure.yaml') + app_client = await aiohttp_client(app.app) + + bearer_header = 'Bearer {"scope": ["myscope"], "uid": "test-user"}' + response = await app_client.get('/v1.0/bearer_auth', headers={'Authorization': bearer_header}) + assert response.status == 200 + assert (await response.json()) == {"scope": ['myscope'], "uid": 'test-user'} + + +async def test_async_secure(aiohttp_api_spec_dir, aiohttp_client): + app = AioHttpApp(__name__, port=5001, specification_dir=aiohttp_api_spec_dir, debug=True) + app.add_api('openapi_secure.yaml', pass_context_arg_name='request') + app_client = await aiohttp_client(app.app) + + response = await app_client.get('/v1.0/async_auth') + assert response.status == 401 + assert response.content_type == 'application/problem+json' + + bearer_header = 'Bearer {"scope": ["myscope"], "uid": "test-user"}' + response = await app_client.get('/v1.0/async_auth', headers={'Authorization': bearer_header}) + assert response.status == 200 + assert (await response.json()) == {"scope": ['myscope'], "uid": 'test-user'} + + bearer_header = 'Bearer {"scope": ["myscope", "other_scope"], "uid": "test-user"}' + response = await app_client.get('/v1.0/async_auth', headers={'Authorization': bearer_header}) + assert response.status == 403, "async_scope_validation should deny access if scopes are not strictly the same" + + basic_header = 'Basic ' + base64.b64encode(b'username:username').decode('ascii') + response = await app_client.get('/v1.0/async_auth', headers={'Authorization': basic_header}) + assert response.status == 200 + assert (await response.json()) == {"uid": 'username'} + + basic_header = 'Basic ' + base64.b64encode(b'username:wrong').decode('ascii') + response = await app_client.get('/v1.0/async_auth', headers={'Authorization': basic_header}) + assert response.status == 401, "Wrong password should trigger unauthorized" + assert response.content_type == 'application/problem+json' + + response = await app_client.get('/v1.0/all_auth', headers={'X-API-Key': '{"foo": "bar"}'}) + assert response.status == 200 + assert (await response.json()) == {"foo": "bar"} + + bearer_header = 'Bearer {"scope": ["myscope"], "uid": "test-user"}' + response = await app_client.get('/v1.0/async_bearer_auth', headers={'Authorization': bearer_header}) + assert response.status == 200 + assert (await response.json()) == {"scope": ['myscope'], "uid": 'test-user'} + +@pytest.mark.parametrize('spec', ['swagger_secure.yaml', 'openapi_secure.yaml']) +async def test_auth_exception_swagger(oauth_aiohttp_client, aiohttp_api_spec_dir, aiohttp_client, spec): + app = AioHttpApp(__name__, port=5001, specification_dir=aiohttp_api_spec_dir, debug=True) + app.add_api(spec) + + app_client = await aiohttp_client(app.app) + + response = await app_client.get( + '/v1.0/fail_auth', + headers={'X-API-Key-2': 'foo'} + ) + assert response.status == 401 + assert response.content_type == 'application/problem+json' diff --git a/tests/aiohttp/test_aiohttp_app.py b/tests/aiohttp/test_aiohttp_app.py new file mode 100644 index 0000000..bcfa4a6 --- /dev/null +++ b/tests/aiohttp/test_aiohttp_app.py @@ -0,0 +1,159 @@ +import logging +import os +import pathlib +from unittest import mock + +import pytest +from conftest import TEST_FOLDER +from firetail import AioHttpApp +from firetail.exceptions import FiretailException + + +@pytest.fixture +def web_run_app_mock(monkeypatch): + mock_ = mock.MagicMock() + monkeypatch.setattr('firetail.apps.aiohttp_app.web.run_app', mock_) + return mock_ + + +@pytest.fixture +def sys_modules_mock(monkeypatch): + monkeypatch.setattr('firetail.apps.aiohttp_app.sys.modules', {}) + + +def test_app_run(web_run_app_mock, aiohttp_api_spec_dir): + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir, + debug=True) + app.run(use_default_access_log=True) + logger = logging.getLogger('firetail.aiohttp_app') + assert web_run_app_mock.call_args_list == [ + mock.call(app.app, port=5001, host='0.0.0.0', access_log=logger) + ] + + +def test_app_run_new_port(web_run_app_mock, aiohttp_api_spec_dir): + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir, + debug=True) + app.run(port=5002) + assert web_run_app_mock.call_args_list == [ + mock.call(app.app, port=5002, host='0.0.0.0', access_log=None) + ] + + +def test_app_run_default_port(web_run_app_mock, aiohttp_api_spec_dir): + app = AioHttpApp(__name__, + specification_dir=aiohttp_api_spec_dir, + debug=True) + app.run() + assert web_run_app_mock.call_args_list == [ + mock.call(app.app, port=5000, host='0.0.0.0', access_log=None) + ] + + +def test_app_run_debug(web_run_app_mock, aiohttp_api_spec_dir): + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir) + app.add_api('swagger_simple.yaml') + app.run(debug=True) + assert web_run_app_mock.call_args_list == [ + mock.call(app.app, port=5001, host='0.0.0.0', access_log=None) + ] + + +def test_app_run_access_log(web_run_app_mock, aiohttp_api_spec_dir): + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir, + debug=True) + logger = logging.getLogger('firetail.aiohttp_app') + app.run(access_log=logger) + assert web_run_app_mock.call_args_list == [ + mock.call(app.app, port=5001, host='0.0.0.0', access_log=logger) + ] + + +def test_app_run_server_error(web_run_app_mock, aiohttp_api_spec_dir): + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir) + + with pytest.raises(Exception) as exc_info: + app.run(server='other') + + assert exc_info.value.args == ('Server other not recognized',) + + +def test_app_get_root_path_return_Path(aiohttp_api_spec_dir): + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir) + assert isinstance(app.get_root_path(), pathlib.Path) == True + + +def test_app_get_root_path_exists(aiohttp_api_spec_dir): + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir) + assert app.get_root_path().exists() == True + + +def test_app_get_root_path(aiohttp_api_spec_dir): + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir) + root_path = app.get_root_path() + assert str(root_path).endswith(os.path.join('tests', 'aiohttp')) == True + + +def test_app_get_root_path_not_in_sys_modules(sys_modules_mock, aiohttp_api_spec_dir): + app = AioHttpApp('firetail', port=5001, + specification_dir=aiohttp_api_spec_dir) + root_path = app.get_root_path() + assert str(root_path).endswith(os.sep + 'firetail') == True + + +def test_app_get_root_path_invalid(sys_modules_mock, aiohttp_api_spec_dir): + with pytest.raises(RuntimeError) as exc_info: + AioHttpApp('error__', port=5001, + specification_dir=aiohttp_api_spec_dir) + + assert exc_info.value.args == ("Invalid import name 'error__'",) + + +def test_app_with_empty_base_path_error(aiohttp_api_spec_dir): + spec_dir = '..' / aiohttp_api_spec_dir.relative_to(TEST_FOLDER) + app = AioHttpApp(__name__, port=5001, + specification_dir=spec_dir, + debug=True) + with pytest.raises(FiretailException) as exc_info: + app.add_api('swagger_empty_base_path.yaml') + + assert exc_info.value.args == ( + "aiohttp doesn't allow to set empty base_path ('/'), " + "use non-empty instead, e.g /api", + ) + + +def test_app_with_empty_base_path_and_only_one_api(aiohttp_api_spec_dir): + spec_dir = '..' / aiohttp_api_spec_dir.relative_to(TEST_FOLDER) + app = AioHttpApp(__name__, port=5001, + specification_dir=spec_dir, + debug=True, + only_one_api=True) + api = app.add_api('swagger_empty_base_path.yaml') + assert api is app.app + + +def test_app_add_two_apis_error_with_only_one_api(aiohttp_api_spec_dir): + spec_dir = '..' / aiohttp_api_spec_dir.relative_to(TEST_FOLDER) + app = AioHttpApp(__name__, port=5001, + specification_dir=spec_dir, + debug=True, + only_one_api=True) + app.add_api('swagger_empty_base_path.yaml') + + with pytest.raises(FiretailException) as exc_info: + app.add_api('swagger_empty_base_path.yaml') + + assert exc_info.value.args == ( + "an api was already added, " + "create a new app with 'only_one_api=False' " + "to add more than one api", + ) diff --git a/tests/aiohttp/test_aiohttp_datetime.py b/tests/aiohttp/test_aiohttp_datetime.py new file mode 100644 index 0000000..1450875 --- /dev/null +++ b/tests/aiohttp/test_aiohttp_datetime.py @@ -0,0 +1,49 @@ +from firetail import AioHttpApp + +try: + import ujson as json +except ImportError: + import json + + +async def test_swagger_json(aiohttp_api_spec_dir, aiohttp_client): + """ Verify the swagger.json file is returned for default setting passed to app. """ + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir, + debug=True) + app.add_api('datetime_support.yaml') + + app_client = await aiohttp_client(app.app) + swagger_json = await app_client.get('/v1.0/openapi.json') + spec_data = await swagger_json.json() + + def get_value(data, path): + for part in path.split('.'): + data = data.get(part) + assert data, f"No data in part '{part}' of '{path}'" + return data + + example = get_value(spec_data, 'paths./datetime.get.responses.200.content.application/json.schema.example.value') + assert example in [ + '2000-01-23T04:56:07.000008+00:00', # PyYAML 5.3 + '2000-01-23T04:56:07.000008Z' + ] + example = get_value(spec_data, 'paths./date.get.responses.200.content.application/json.schema.example.value') + assert example == '2000-01-23' + example = get_value(spec_data, 'paths./uuid.get.responses.200.content.application/json.schema.example.value') + assert example == 'a7b8869c-5f24-4ce0-a5d1-3e44c3663aa9' + + resp = await app_client.get('/v1.0/datetime') + assert resp.status == 200 + json_data = await resp.json() + assert json_data == {'value': '2000-01-02T03:04:05.000006Z'} + + resp = await app_client.get('/v1.0/date') + assert resp.status == 200 + json_data = await resp.json() + assert json_data == {'value': '2000-01-02'} + + resp = await app_client.get('/v1.0/uuid') + assert resp.status == 200 + json_data = await resp.json() + assert json_data == {'value': 'e7ff66d0-3ec2-4c4e-bed0-6e4723c24c51'} diff --git a/tests/aiohttp/test_aiohttp_errors.py b/tests/aiohttp/test_aiohttp_errors.py new file mode 100644 index 0000000..561003f --- /dev/null +++ b/tests/aiohttp/test_aiohttp_errors.py @@ -0,0 +1,136 @@ +import asyncio + +import aiohttp.test_utils +import pytest +from firetail import AioHttpApp +from firetail.apis.aiohttp_api import HTTPStatus + + +def is_valid_problem_json(json_body): + return all(key in json_body for key in ["type", "title", "detail", "status"]) + + +@pytest.fixture +def aiohttp_app(problem_api_spec_dir): + app = AioHttpApp(__name__, port=5001, + specification_dir=problem_api_spec_dir, + debug=True) + options = {"validate_responses": True} + app.add_api('openapi.yaml', validate_responses=True, pass_context_arg_name='request_ctx', options=options) + return app + + +async def test_aiohttp_problems_404(aiohttp_app, aiohttp_client): + # TODO: This is a based on test_errors.test_errors(). That should be refactored + # so that it is parameterized for all web frameworks. + app_client = await aiohttp_client(aiohttp_app.app) # type: aiohttp.test_utils.TestClient + + greeting404 = await app_client.get('/v1.0/greeting') # type: aiohttp.ClientResponse + assert greeting404.content_type == 'application/problem+json' + assert greeting404.status == 404 + error404 = await greeting404.json() + assert is_valid_problem_json(error404) + assert error404['type'] == 'about:blank' + assert error404['title'] == 'Not Found' + assert error404['detail'] == HTTPStatus(404).description + assert error404['status'] == 404 + assert 'instance' not in error404 + + +async def test_aiohttp_problems_405(aiohttp_app, aiohttp_client): + # TODO: This is a based on test_errors.test_errors(). That should be refactored + # so that it is parameterized for all web frameworks. + app_client = await aiohttp_client(aiohttp_app.app) # type: aiohttp.test_utils.TestClient + + get_greeting = await app_client.get('/v1.0/greeting/jsantos') # type: aiohttp.ClientResponse + assert get_greeting.content_type == 'application/problem+json' + assert get_greeting.status == 405 + error405 = await get_greeting.json() + assert is_valid_problem_json(error405) + assert error405['type'] == 'about:blank' + assert error405['title'] == 'Method Not Allowed' + assert error405['detail'] == HTTPStatus(405).description + assert error405['status'] == 405 + assert 'instance' not in error405 + + +async def test_aiohttp_problems_500(aiohttp_app, aiohttp_client): + # TODO: This is a based on test_errors.test_errors(). That should be refactored + # so that it is parameterized for all web frameworks. + app_client = await aiohttp_client(aiohttp_app.app) # type: aiohttp.test_utils.TestClient + + get500 = await app_client.get('/v1.0/except') # type: aiohttp.ClientResponse + assert get500.content_type == 'application/problem+json' + assert get500.status == 500 + error500 = await get500.json() + assert is_valid_problem_json(error500) + assert error500['type'] == 'about:blank' + assert error500['title'] == 'Internal Server Error' + assert error500['detail'] == HTTPStatus(500).description + assert error500['status'] == 500 + assert 'instance' not in error500 + + +async def test_aiohttp_problems_418(aiohttp_app, aiohttp_client): + # TODO: This is a based on test_errors.test_errors(). That should be refactored + # so that it is parameterized for all web frameworks. + app_client = await aiohttp_client(aiohttp_app.app) # type: aiohttp.test_utils.TestClient + + get_problem = await app_client.get('/v1.0/problem') # type: aiohttp.ClientResponse + assert get_problem.content_type == 'application/problem+json' + assert get_problem.status == 418 + assert get_problem.headers['x-Test-Header'] == 'In Test' + error_problem = await get_problem.json() + assert is_valid_problem_json(error_problem) + assert error_problem['type'] == 'http://www.example.com/error' + assert error_problem['title'] == 'Some Error' + assert error_problem['detail'] == 'Something went wrong somewhere' + assert error_problem['status'] == 418 + assert error_problem['instance'] == 'instance1' + + +async def test_aiohttp_problems_misc(aiohttp_app, aiohttp_client): + # TODO: This is a based on test_errors.test_errors(). That should be refactored + # so that it is parameterized for all web frameworks. + app_client = await aiohttp_client(aiohttp_app.app) # type: aiohttp.test_utils.TestClient + + problematic_json = await app_client.get( + '/v1.0/json_response_with_undefined_value_to_serialize') # type: aiohttp.ClientResponse + assert problematic_json.content_type == 'application/problem+json' + assert problematic_json.status == 500 + problematic_json_body = await problematic_json.json() + assert is_valid_problem_json(problematic_json_body) + + custom_problem = await app_client.get('/v1.0/customized_problem_response') # type: aiohttp.ClientResponse + assert custom_problem.content_type == 'application/problem+json' + assert custom_problem.status == 403 + problem_body = await custom_problem.json() + assert is_valid_problem_json(problem_body) + assert 'amount' in problem_body + + problem_as_exception = await app_client.get('/v1.0/problem_exception_with_extra_args') # type: aiohttp.ClientResponse + assert problem_as_exception.content_type == "application/problem+json" + assert problem_as_exception.status == 400 + problem_as_exception_body = await problem_as_exception.json() + assert is_valid_problem_json(problem_as_exception_body) + assert 'age' in problem_as_exception_body + assert problem_as_exception_body['age'] == 30 + + +@pytest.mark.skip(reason="aiohttp_api.get_firetail_response uses _cast_body " + "to stringify the dict directly instead of using json.dumps. " + "This differs from flask usage, where there is no _cast_body.") +async def test_aiohttp_problem_with_text_content_type(aiohttp_app, aiohttp_client): + app_client = await aiohttp_client(aiohttp_app.app) # type: aiohttp.test_utils.TestClient + + get_problem2 = await app_client.get('/v1.0/other_problem') # type: aiohttp.ClientResponse + assert get_problem2.content_type == 'application/problem+json' + assert get_problem2.status == 418 + error_problem2 = await get_problem2.json() + assert is_valid_problem_json(error_problem2) + assert error_problem2['type'] == 'about:blank' + assert error_problem2['title'] == 'Some Error' + assert error_problem2['detail'] == 'Something went wrong somewhere' + assert error_problem2['status'] == 418 + assert error_problem2['instance'] == 'instance1' + diff --git a/tests/aiohttp/test_aiohttp_multipart.py b/tests/aiohttp/test_aiohttp_multipart.py new file mode 100644 index 0000000..89429a7 --- /dev/null +++ b/tests/aiohttp/test_aiohttp_multipart.py @@ -0,0 +1,117 @@ +import os +from pathlib import Path + +import aiohttp +import pytest +from firetail import AioHttpApp + +try: + import ujson as json +except ImportError: + import json + + +@pytest.fixture +def aiohttp_app(aiohttp_api_spec_dir): + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir, + debug=True) + app.add_api( + 'openapi_multipart.yaml', + validate_responses=True, + strict_validation=True, + pythonic_params=True, + pass_context_arg_name='request_ctx', + ) + return app + + +async def test_single_file_upload(aiohttp_app, aiohttp_client): + app_client = await aiohttp_client(aiohttp_app.app) + + resp = await app_client.post( + '/v1.0/upload_file', + data=aiohttp.FormData(fields=[('myfile', open(__file__, 'rb'))])(), + ) + + data = await resp.json() + assert resp.status == 200 + assert data['fileName'] == f'{__name__}.py' + assert data['myfile_content'] == Path(__file__).read_bytes().decode('utf8') + + +async def test_many_files_upload(aiohttp_app, aiohttp_client): + app_client = await aiohttp_client(aiohttp_app.app) + + dir_name = os.path.dirname(__file__) + files_field = [ + ('myfiles', open(f'{dir_name}/{file_name}', 'rb')) \ + for file_name in sorted(os.listdir(dir_name)) if file_name.endswith('.py') + ] + + form_data = aiohttp.FormData(fields=files_field) + + resp = await app_client.post( + '/v1.0/upload_files', + data=form_data(), + ) + + data = await resp.json() + + assert resp.status == 200 + assert data['files_count'] == len(files_field) + assert data['myfiles_content'] == [ + Path(f'{dir_name}/{file_name}').read_bytes().decode('utf8') \ + for file_name in sorted(os.listdir(dir_name)) if file_name.endswith('.py') + ] + + +async def test_mixed_multipart_single_file(aiohttp_app, aiohttp_client): + app_client = await aiohttp_client(aiohttp_app.app) + + form_data = aiohttp.FormData() + form_data.add_field('dir_name', os.path.dirname(__file__)) + form_data.add_field('myfile', open(__file__, 'rb')) + + resp = await app_client.post( + '/v1.0/mixed_single_file', + data=form_data(), + ) + + data = await resp.json() + + assert resp.status == 200 + assert data['dir_name'] == os.path.dirname(__file__) + assert data['fileName'] == f'{__name__}.py' + assert data['myfile_content'] == Path(__file__).read_bytes().decode('utf8') + + + +async def test_mixed_multipart_many_files(aiohttp_app, aiohttp_client): + app_client = await aiohttp_client(aiohttp_app.app) + + dir_name = os.path.dirname(__file__) + files_field = [ + ('myfiles', open(f'{dir_name}/{file_name}', 'rb')) \ + for file_name in sorted(os.listdir(dir_name)) if file_name.endswith('.py') + ] + + form_data = aiohttp.FormData(fields=files_field) + form_data.add_field('dir_name', os.path.dirname(__file__)) + form_data.add_field('test_count', str(len(files_field))) + + resp = await app_client.post( + '/v1.0/mixed_many_files', + data=form_data(), + ) + + data = await resp.json() + + assert resp.status == 200 + assert data['dir_name'] == os.path.dirname(__file__) + assert data['test_count'] == len(files_field) + assert data['files_count'] == len(files_field) + assert data['myfiles_content'] == [ + Path(f'{dir_name}/{file_name}').read_bytes().decode('utf8') \ + for file_name in sorted(os.listdir(dir_name)) if file_name.endswith('.py') + ] diff --git a/tests/aiohttp/test_aiohttp_reverse_proxy.py b/tests/aiohttp/test_aiohttp_reverse_proxy.py new file mode 100644 index 0000000..7a87808 --- /dev/null +++ b/tests/aiohttp/test_aiohttp_reverse_proxy.py @@ -0,0 +1,128 @@ +import asyncio + +from aiohttp import web +from aiohttp_remotes.exceptions import RemoteError, TooManyHeaders +from aiohttp_remotes.x_forwarded import XForwardedBase +from firetail import AioHttpApp +from yarl import URL + +X_FORWARDED_PATH = "X-Forwarded-Path" + + +class XPathForwarded(XForwardedBase): + + def __init__(self, num=1): + self._num = num + + def get_forwarded_path(self, headers): + forwarded_host = headers.getall(X_FORWARDED_PATH, []) + if len(forwarded_host) > 1: + raise TooManyHeaders(X_FORWARDED_PATH) + return forwarded_host[0] if forwarded_host else None + + @web.middleware + async def middleware(self, request, handler): + try: + overrides = {} + headers = request.headers + + forwarded_for = self.get_forwarded_for(headers) + if forwarded_for: + overrides['remote'] = str(forwarded_for[-self._num]) + + proto = self.get_forwarded_proto(headers) + if proto: + overrides['scheme'] = proto[-self._num] + + host = self.get_forwarded_host(headers) + if host is not None: + overrides['host'] = host + + prefix = self.get_forwarded_path(headers) + if prefix is not None: + prefix = '/' + prefix.strip('/') + '/' + request_path = URL(request.path.lstrip('/')) + overrides['rel_url'] = URL(prefix).join(request_path) + + request = request.clone(**overrides) + + return await handler(request) + except RemoteError as exc: + exc.log(request) + await self.raise_error(request) + + + async def test_swagger_json_behind_proxy(simple_api_spec_dir, aiohttp_client): + """ Verify the swagger.json file is returned with base_path updated + according to X-Forwarded-Path header. """ + app = AioHttpApp(__name__, port=5001, + specification_dir=simple_api_spec_dir, + debug=True) + api = app.add_api('swagger.yaml') + + aio = app.app + reverse_proxied = XPathForwarded() + aio.middlewares.append(reverse_proxied.middleware) + + app_client = await aiohttp_client(app.app) + headers = {'X-Forwarded-Path': '/behind/proxy'} + + swagger_ui = await app_client.get('/v1.0/ui/', headers=headers) + assert swagger_ui.status == 200 + assert b'url = "/behind/proxy/v1.0/swagger.json"' in ( + await swagger_ui.read() + ) + + swagger_json = await app_client.get('/v1.0/swagger.json', headers=headers) + assert swagger_json.status == 200 + assert swagger_json.headers.get('Content-Type') == 'application/json' + json_ = await swagger_json.json() + + assert api.specification.raw['basePath'] == '/v1.0', \ + "Original specifications should not have been changed" + + assert json_.get('basePath') == '/behind/proxy/v1.0', \ + "basePath should contains original URI" + + json_['basePath'] = api.specification.raw['basePath'] + assert api.specification.raw == json_, \ + "Only basePath should have been updated" + + + async def test_openapi_json_behind_proxy(simple_api_spec_dir, aiohttp_client): + """ Verify the swagger.json file is returned with base_path updated + according to X-Forwarded-Path header. """ + app = AioHttpApp(__name__, port=5001, + specification_dir=simple_api_spec_dir, + debug=True) + + api = app.add_api('openapi.yaml') + + aio = app.app + reverse_proxied = XPathForwarded() + aio.middlewares.append(reverse_proxied.middleware) + + app_client = await aiohttp_client(app.app) + headers = {'X-Forwarded-Path': '/behind/proxy'} + + swagger_ui = await app_client.get('/v1.0/ui/', headers=headers) + assert swagger_ui.status == 200 + assert b'url: "/behind/proxy/v1.0/openapi.json"' in ( + await swagger_ui.read() + ) + + swagger_json = await app_client.get('/v1.0/openapi.json', headers=headers) + assert swagger_json.status == 200 + assert swagger_json.headers.get('Content-Type') == 'application/json' + json_ = await swagger_json.json() + + assert json_.get('servers', [{}])[0].get('url') == '/behind/proxy/v1.0', \ + "basePath should contains original URI" + + url = api.specification.raw.get('servers', [{}])[0].get('url') + assert url != '/behind/proxy/v1.0', \ + "Original specifications should not have been changed" + + json_['servers'] = api.specification.raw.get('servers') + assert api.specification.raw == json_, \ + "Only there servers block should have been updated" diff --git a/tests/aiohttp/test_aiohttp_simple_api.py b/tests/aiohttp/test_aiohttp_simple_api.py new file mode 100644 index 0000000..8ba587c --- /dev/null +++ b/tests/aiohttp/test_aiohttp_simple_api.py @@ -0,0 +1,376 @@ +import sys + +import pytest +import yaml +from conftest import TEST_FOLDER +from firetail import AioHttpApp + +try: + import ujson as json +except ImportError: + import json + + +@pytest.fixture +def aiohttp_app(aiohttp_api_spec_dir): + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir, + debug=True) + options = {"validate_responses": True} + app.add_api('swagger_simple.yaml', validate_responses=True, pass_context_arg_name='request_ctx', options=options) + return app + + +async def test_app(aiohttp_app, aiohttp_client): + # Create the app and run the test_app testcase below. + app_client = await aiohttp_client(aiohttp_app.app) + get_bye = await app_client.get('/v1.0/bye/jsantos') + assert get_bye.status == 200 + assert (await get_bye.read()) == b'Goodbye jsantos' + + +async def test_app_with_relative_path(aiohttp_api_spec_dir, aiohttp_client): + # Create the app with a relative path and run the test_app testcase below. + app = AioHttpApp(__name__, port=5001, + specification_dir='..' / + aiohttp_api_spec_dir.relative_to(TEST_FOLDER), + debug=True) + app.add_api('swagger_simple.yaml') + app_client = await aiohttp_client(app.app) + get_bye = await app_client.get('/v1.0/bye/jsantos') + assert get_bye.status == 200 + assert (await get_bye.read()) == b'Goodbye jsantos' + + +async def test_swagger_json(aiohttp_api_spec_dir, aiohttp_client): + """ Verify the swagger.json file is returned for default setting passed to app. """ + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir, + debug=True) + api = app.add_api('swagger_simple.yaml') + + app_client = await aiohttp_client(app.app) + swagger_json = await app_client.get('/v1.0/swagger.json') + + assert swagger_json.status == 200 + json_ = await swagger_json.json() + assert api.specification.raw == json_ + + +async def test_swagger_yaml(aiohttp_api_spec_dir, aiohttp_client): + """ Verify the swagger.yaml file is returned for default setting passed to app. """ + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir, + debug=True) + api = app.add_api('swagger_simple.yaml') + + app_client = await aiohttp_client(app.app) + spec_response = await app_client.get('/v1.0/swagger.yaml') + data_ = await spec_response.read() + + assert spec_response.status == 200 + assert api.specification.raw == yaml.load(data_, yaml.FullLoader) + + +async def test_no_swagger_json(aiohttp_api_spec_dir, aiohttp_client): + """ Verify the swagger.json file is not returned when set to False when creating app. """ + options = {"swagger_json": False} + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir, + options=options, + debug=True) + app.add_api('swagger_simple.yaml') + + app_client = await aiohttp_client(app.app) + swagger_json = await app_client.get('/v1.0/swagger.json') # type: flask.Response + assert swagger_json.status == 404 + + +async def test_no_swagger_yaml(aiohttp_api_spec_dir, aiohttp_client): + """ Verify the swagger.json file is not returned when set to False when creating app. """ + options = {"swagger_json": False} + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir, + options=options, + debug=True) + app.add_api('swagger_simple.yaml') + + app_client = await aiohttp_client(app.app) + spec_response = await app_client.get('/v1.0/swagger.yaml') # type: flask.Response + assert spec_response.status == 404 + + +async def test_swagger_ui(aiohttp_api_spec_dir, aiohttp_client): + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir, + debug=True) + app.add_api('swagger_simple.yaml') + + app_client = await aiohttp_client(app.app) + swagger_ui = await app_client.get('/v1.0/ui') + assert swagger_ui.status == 200 + assert swagger_ui.url.path == '/v1.0/ui/' + assert b'url = "/v1.0/swagger.json"' in (await swagger_ui.read()) + + swagger_ui = await app_client.get('/v1.0/ui/') + assert swagger_ui.status == 200 + assert b'url = "/v1.0/swagger.json"' in (await swagger_ui.read()) + + +async def test_swagger_ui_config_json(aiohttp_api_spec_dir, aiohttp_client): + """ Verify the swagger-ui-config.json file is returned for swagger_ui_config option passed to app. """ + swagger_ui_config = {"displayOperationId": True} + options = {"swagger_ui_config": swagger_ui_config} + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir, + options=options, + debug=True) + api = app.add_api('swagger_simple.yaml') + + app_client = await aiohttp_client(app.app) + swagger_ui_config_json = await app_client.get('/v1.0/ui/swagger-ui-config.json') + json_ = await swagger_ui_config_json.read() + + assert swagger_ui_config_json.status == 200 + assert swagger_ui_config == json.loads(json_) + + +async def test_no_swagger_ui_config_json(aiohttp_api_spec_dir, aiohttp_client): + """ Verify the swagger-ui-config.json file is not returned when the swagger_ui_config option not passed to app. """ + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir, + debug=True) + app.add_api('swagger_simple.yaml') + + app_client = await aiohttp_client(app.app) + swagger_ui_config_json = await app_client.get('/v1.0/ui/swagger-ui-config.json') + assert swagger_ui_config_json.status == 404 + + +async def test_swagger_ui_index(aiohttp_api_spec_dir, aiohttp_client): + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir, + debug=True) + app.add_api('openapi_secure.yaml') + + app_client = await aiohttp_client(app.app) + swagger_ui = await app_client.get('/v1.0/ui/index.html') + assert swagger_ui.status == 200 + assert b'url: "/v1.0/openapi.json"' in (await swagger_ui.read()) + assert b'swagger-ui-config.json' not in (await swagger_ui.read()) + + +async def test_swagger_ui_index_with_config(aiohttp_api_spec_dir, aiohttp_client): + swagger_ui_config = {"displayOperationId": True} + options = {"swagger_ui_config": swagger_ui_config} + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir, + options=options, + debug=True) + app.add_api('openapi_secure.yaml') + + app_client = await aiohttp_client(app.app) + swagger_ui = await app_client.get('/v1.0/ui/index.html') + assert swagger_ui.status == 200 + assert b'configUrl: "swagger-ui-config.json"' in (await swagger_ui.read()) + + +async def test_pythonic_path_param(aiohttp_api_spec_dir, aiohttp_client): + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir, + debug=True) + app.add_api('openapi_simple.yaml', pythonic_params=True) + + app_client = await aiohttp_client(app.app) + pythonic = await app_client.get('/v1.0/pythonic/100') + assert pythonic.status == 200 + j = await pythonic.json() + assert j['id_'] == 100 + + +async def test_cookie_param(aiohttp_api_spec_dir, aiohttp_client): + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir, + debug=True) + app.add_api('openapi_simple.yaml', pass_context_arg_name="request") + + app_client = await aiohttp_client(app.app) + response = await app_client.get('/v1.0/test-cookie-param', headers={"Cookie": "test_cookie=hello"}) + assert response.status == 200 + j = await response.json() + assert j['cookie_value'] == "hello" + + +async def test_swagger_ui_static(aiohttp_api_spec_dir, aiohttp_client): + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir, + debug=True) + app.add_api('swagger_simple.yaml') + + app_client = await aiohttp_client(app.app) + swagger_ui = await app_client.get('/v1.0/ui/lib/swagger-oauth.js') + assert swagger_ui.status == 200 + + app_client = await aiohttp_client(app.app) + swagger_ui = await app_client.get('/v1.0/ui/swagger-ui.min.js') + assert swagger_ui.status == 200 + + +async def test_no_swagger_ui(aiohttp_api_spec_dir, aiohttp_client): + options = {"swagger_ui": False} + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir, + options=options, debug=True) + app.add_api('swagger_simple.yaml') + + app_client = await aiohttp_client(app.app) + swagger_ui = await app_client.get('/v1.0/ui/') + assert swagger_ui.status == 404 + + app2 = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir, + debug=True) + options = {"swagger_ui": False} + app2.add_api('swagger_simple.yaml', options=options) + app2_client = await aiohttp_client(app.app) + swagger_ui2 = await app2_client.get('/v1.0/ui/') + assert swagger_ui2.status == 404 + + +async def test_middlewares(aiohttp_api_spec_dir, aiohttp_client): + async def middleware(app, handler): + async def middleware_handler(request): + response = (await handler(request)) + response.body += b' middleware' + return response + + return middleware_handler + + options = {"middlewares": [middleware]} + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir, + debug=True, options=options) + app.add_api('swagger_simple.yaml') + app_client = await aiohttp_client(app.app) + get_bye = await app_client.get('/v1.0/bye/jsantos') + assert get_bye.status == 200 + assert (await get_bye.read()) == b'Goodbye jsantos middleware' + + +async def test_response_with_str_body(aiohttp_app, aiohttp_client): + # Create the app and run the test_app testcase below. + app_client = await aiohttp_client(aiohttp_app.app) + get_bye = await app_client.get('/v1.0/aiohttp_str_response') + assert get_bye.status == 200 + assert (await get_bye.read()) == b'str response' + + +async def test_response_with_non_str_and_non_json_body(aiohttp_app, aiohttp_client): + app_client = await aiohttp_client(aiohttp_app.app) + get_bye = await app_client.get( + '/v1.0/aiohttp_non_str_non_json_response' + ) + assert get_bye.status == 200 + assert (await get_bye.read()) == b'1234' + + +async def test_response_with_bytes_body(aiohttp_app, aiohttp_client): + # Create the app and run the test_app testcase below. + app_client = await aiohttp_client(aiohttp_app.app) + get_bye = await app_client.get('/v1.0/aiohttp_bytes_response') + assert get_bye.status == 200 + assert (await get_bye.read()) == b'bytes response' + + +async def test_validate_responses(aiohttp_app, aiohttp_client): + app_client = await aiohttp_client(aiohttp_app.app) + get_bye = await app_client.get('/v1.0/aiohttp_validate_responses') + assert get_bye.status == 200 + assert (await get_bye.json()) == {"validate": True} + + +async def test_get_users(aiohttp_client, aiohttp_app): + app_client = await aiohttp_client(aiohttp_app.app) + resp = await app_client.get('/v1.0/users') + assert resp.url.path == '/v1.0/users/' # followed redirect + assert resp.status == 200 + + json_data = await resp.json() + assert json_data == \ + [{'name': 'John Doe', 'id': 1}, {'name': 'Nick Carlson', 'id': 2}] + + +async def test_create_user(aiohttp_client, aiohttp_app): + app_client = await aiohttp_client(aiohttp_app.app) + user = {'name': 'Maksim'} + resp = await app_client.post('/v1.0/users', json=user, headers={'Content-type': 'application/json'}) + assert resp.status == 201 + + +async def test_access_request_context(aiohttp_client, aiohttp_app): + app_client = await aiohttp_client(aiohttp_app.app) + resp = await app_client.post('/v1.0/aiohttp_access_request_context/') + assert resp.status == 204 + + +async def test_query_parsing_simple(aiohttp_client, aiohttp_app): + expected_query = 'query' + + app_client = await aiohttp_client(aiohttp_app.app) + resp = await app_client.get( + '/v1.0/aiohttp_query_parsing_str', + params={ + 'query': expected_query, + }, + ) + assert resp.status == 200 + + json_data = await resp.json() + assert json_data == {'query': expected_query} + + +async def test_query_parsing_array(aiohttp_client, aiohttp_app): + expected_query = ['queryA', 'queryB'] + + app_client = await aiohttp_client(aiohttp_app.app) + resp = await app_client.get( + '/v1.0/aiohttp_query_parsing_array', + params={ + 'query': ','.join(expected_query), + }, + ) + assert resp.status == 200 + + json_data = await resp.json() + assert json_data == {'query': expected_query} + + +async def test_query_parsing_array_multi(aiohttp_client, aiohttp_app): + expected_query = ['queryA', 'queryB', 'queryC'] + query_str = '&'.join(['query=%s' % q for q in expected_query]) + + app_client = await aiohttp_client(aiohttp_app.app) + resp = await app_client.get( + '/v1.0/aiohttp_query_parsing_array_multi?%s' % query_str, + ) + assert resp.status == 200 + + json_data = await resp.json() + assert json_data == {'query': expected_query} + + +if sys.version_info[0:2] >= (3, 5): + @pytest.fixture + def aiohttp_app_async_def(aiohttp_api_spec_dir): + app = AioHttpApp(__name__, port=5001, + specification_dir=aiohttp_api_spec_dir, + debug=True) + app.add_api('swagger_simple_async_def.yaml', validate_responses=True) + return app + + + async def test_validate_responses_async_def(aiohttp_app_async_def, aiohttp_client): + app_client = await aiohttp_client(aiohttp_app_async_def.app) + get_bye = await app_client.get('/v1.0/aiohttp_validate_responses') + assert get_bye.status == 200 + assert (await get_bye.read()) == b'{"validate": true}' diff --git a/tests/aiohttp/test_get_response.py b/tests/aiohttp/test_get_response.py new file mode 100644 index 0000000..dee1322 --- /dev/null +++ b/tests/aiohttp/test_get_response.py @@ -0,0 +1,171 @@ +import json + +import pytest +from aiohttp import web +from firetail.apis.aiohttp_api import AioHttpApi +from firetail.lifecycle import FiretailResponse + + +@pytest.fixture(scope='module') +def api(aiohttp_api_spec_dir): + yield AioHttpApi(specification=aiohttp_api_spec_dir / 'swagger_secure.yaml') + + +async def test_get_response_from_aiohttp_response(api): + response = await api.get_response(web.Response(text='foo', status=201, headers={'X-header': 'value'})) + assert isinstance(response, web.Response) + assert response.status == 201 + assert response.body == b'foo' + assert response.content_type == 'text/plain' + assert dict(response.headers) == {'Content-Type': 'text/plain; charset=utf-8', 'X-header': 'value'} + + +async def test_get_response_from_aiohttp_stream_response(api): + response = await api.get_response(web.StreamResponse(status=201, headers={'X-header': 'value'})) + assert isinstance(response, web.StreamResponse) + assert response.status == 201 + assert response.content_type == 'application/octet-stream' + assert dict(response.headers) == {'X-header': 'value'} + + +async def test_get_response_from_firetail_response(api): + response = await api.get_response(FiretailResponse(status_code=201, mimetype='text/plain', body='foo', headers={'X-header': 'value'})) + assert isinstance(response, web.Response) + assert response.status == 201 + assert response.body == b'foo' + assert response.content_type == 'text/plain' + assert dict(response.headers) == {'Content-Type': 'text/plain; charset=utf-8', 'X-header': 'value'} + + +async def test_get_response_from_string(api): + response = await api.get_response('foo') + assert isinstance(response, web.Response) + assert response.status == 200 + assert response.body == b'foo' + assert response.content_type == 'text/plain' + assert dict(response.headers) == {'Content-Type': 'text/plain; charset=utf-8'} + + +async def test_get_response_from_string_tuple(api): + response = await api.get_response(('foo',)) + assert isinstance(response, web.Response) + assert response.status == 200 + assert response.body == b'foo' + assert response.content_type == 'text/plain' + assert dict(response.headers) == {'Content-Type': 'text/plain; charset=utf-8'} + + +async def test_get_response_from_string_status(api): + response = await api.get_response(('foo', 201)) + assert isinstance(response, web.Response) + assert response.status == 201 + assert response.body == b'foo' + assert response.content_type == 'text/plain' + assert dict(response.headers) == {'Content-Type': 'text/plain; charset=utf-8'} + + +async def test_get_response_from_string_headers(api): + response = await api.get_response(('foo', {'X-header': 'value'})) + assert isinstance(response, web.Response) + assert response.status == 200 + assert response.body == b'foo' + assert response.content_type == 'text/plain' + assert dict(response.headers) == {'Content-Type': 'text/plain; charset=utf-8', 'X-header': 'value'} + + +async def test_get_response_from_string_status_headers(api): + response = await api.get_response(('foo', 201, {'X-header': 'value'})) + assert isinstance(response, web.Response) + assert response.status == 201 + assert response.body == b'foo' + assert response.content_type == 'text/plain' + assert dict(response.headers) == {'Content-Type': 'text/plain; charset=utf-8', 'X-header': 'value'} + + +async def test_get_response_from_tuple_error(api): + with pytest.raises(TypeError) as e: + await api.get_response((web.Response(text='foo', status=201, headers={'X-header': 'value'}), 200)) + assert str(e.value) == "Cannot return web.StreamResponse in tuple. Only raw data can be returned in tuple." + + +async def test_get_response_from_dict(api): + response = await api.get_response({'foo': 'bar'}) + assert isinstance(response, web.Response) + assert response.status == 200 + # odd, yes. but backwards compatible. see test_response_with_non_str_and_non_json_body in tests/aiohttp/test_aiohttp_simple_api.py + # TODO: This should be made into JSON when aiohttp and flask serialization can be harmonized. + assert response.body == b"{'foo': 'bar'}" + assert response.content_type == 'text/plain' + assert dict(response.headers) == {'Content-Type': 'text/plain; charset=utf-8'} + + +async def test_get_response_from_dict_json(api): + response = await api.get_response({'foo': 'bar'}, mimetype='application/json') + assert isinstance(response, web.Response) + assert response.status == 200 + assert json.loads(response.body.decode()) == {"foo": "bar"} + assert response.content_type == 'application/json' + assert dict(response.headers) == {'Content-Type': 'application/json; charset=utf-8'} + + +async def test_get_response_no_data(api): + response = await api.get_response(None, mimetype='application/json') + assert isinstance(response, web.Response) + assert response.status == 204 + assert response.body is None + assert response.content_type == 'application/json' + assert dict(response.headers) == {'Content-Type': 'application/json'} + + +async def test_get_response_binary_json(api): + response = await api.get_response(b'{"foo":"bar"}', mimetype='application/json') + assert isinstance(response, web.Response) + assert response.status == 200 + assert json.loads(response.body.decode()) == {"foo": "bar"} + assert response.content_type == 'application/json' + assert dict(response.headers) == {'Content-Type': 'application/json'} + + +async def test_get_response_binary_no_mimetype(api): + response = await api.get_response(b'{"foo":"bar"}') + assert isinstance(response, web.Response) + assert response.status == 200 + assert response.body == b'{"foo":"bar"}' + assert response.content_type == 'application/octet-stream' + assert dict(response.headers) == {} + + +async def test_get_firetail_response_from_aiohttp_response(api): + response = api.get_firetail_response(web.Response(text='foo', status=201, headers={'X-header': 'value'})) + assert isinstance(response, FiretailResponse) + assert response.status_code == 201 + assert response.body == b'foo' + assert response.content_type == 'text/plain' + assert dict(response.headers) == {'Content-Type': 'text/plain; charset=utf-8', 'X-header': 'value'} + + +async def test_get_firetail_response_from_firetail_response(api): + response = api.get_firetail_response(FiretailResponse(status_code=201, content_type='text/plain', body='foo', headers={'X-header': 'value'})) + assert isinstance(response, FiretailResponse) + assert response.status_code == 201 + assert response.body == b'foo' + assert response.content_type == 'text/plain' + assert dict(response.headers) == {'Content-Type': 'text/plain; charset=utf-8', 'X-header': 'value'} + + +async def test_get_firetail_response_from_tuple(api): + response = api.get_firetail_response(('foo', 201, {'X-header': 'value'})) + assert isinstance(response, FiretailResponse) + assert response.status_code == 201 + assert response.body == b'foo' + assert response.content_type == 'text/plain' + assert dict(response.headers) == {'Content-Type': 'text/plain; charset=utf-8', 'X-header': 'value'} + + +async def test_get_firetail_response_from_aiohttp_stream_response(api): + response = api.get_firetail_response(web.StreamResponse(status=201, headers={'X-header': 'value'})) + assert isinstance(response, FiretailResponse) + assert response.status_code == 201 + assert response.body == None + assert response.content_type == 'application/octet-stream' + assert dict(response.headers) == {'X-header': 'value'} diff --git a/tests/api/test_bootstrap.py b/tests/api/test_bootstrap.py index 2bef7f3..89cf9d8 100644 --- a/tests/api/test_bootstrap.py +++ b/tests/api/test_bootstrap.py @@ -1,340 +1,321 @@ -import json -from unittest import mock - -import jinja2 -import pytest -import yaml -from firetail import App -from firetail.exceptions import InvalidSpecification -from firetail.http_facts import METHODS -from firetail.json_schema import ExtendedSafeLoader - -from conftest import TEST_FOLDER, build_app_from_fixture - -SPECS = ["swagger.yaml", "openapi.yaml"] - - -@pytest.mark.parametrize("spec", SPECS) -def test_app_with_relative_path(simple_api_spec_dir, spec): - # Create the app with a relative path and run the test_app testcase below. - app = App(__name__, port=5001, - specification_dir='..' / - simple_api_spec_dir.relative_to(TEST_FOLDER), - debug=True) - app.add_api(spec) - - app_client = app.app.test_client() - get_bye = app_client.get('/v1.0/bye/jsantos') # type: flask.Response - assert get_bye.status_code == 200 - assert get_bye.data == b'Goodbye jsantos' - - -@pytest.mark.parametrize("spec", SPECS) -def test_app_with_resolver(simple_api_spec_dir, spec): - from firetail.resolver import Resolver - resolver = Resolver() - app = App(__name__, port=5001, - specification_dir='..' / - simple_api_spec_dir.relative_to(TEST_FOLDER), - resolver=resolver) - api = app.add_api(spec) - assert api.resolver is resolver - - -@pytest.mark.parametrize("spec", SPECS) -def test_app_with_different_server_option(simple_api_spec_dir, spec): - # Create the app with a relative path and run the test_app testcase below. - app = App(__name__, port=5001, - server='gevent', - specification_dir='..' / - simple_api_spec_dir.relative_to(TEST_FOLDER), - debug=True) - app.add_api(spec) - - app_client = app.app.test_client() - get_bye = app_client.get('/v1.0/bye/jsantos') # type: flask.Response - assert get_bye.status_code == 200 - assert get_bye.data == b'Goodbye jsantos' - - -def test_app_with_different_uri_parser(simple_api_spec_dir): - from firetail.decorators.uri_parsing import FirstValueURIParser - app = App(__name__, port=5001, - specification_dir='..' / - simple_api_spec_dir.relative_to(TEST_FOLDER), - options={"uri_parser_class": FirstValueURIParser}, - debug=True) - app.add_api('swagger.yaml') - - app_client = app.app.test_client() - resp = app_client.get( - '/v1.0/test_array_csv_query_param?items=a,b,c&items=d,e,f' - ) # type: flask.Response - assert resp.status_code == 200 - j = json.loads(resp.get_data(as_text=True)) - assert j == ['a', 'b', 'c'] - - -@pytest.mark.parametrize("spec", SPECS) -def test_swagger_ui(simple_api_spec_dir, spec): - app = App(__name__, port=5001, - specification_dir=simple_api_spec_dir, debug=True) - app.add_api(spec) - app_client = app.app.test_client() - swagger_ui = app_client.get('/v1.0/ui/') # type: flask.Response - assert swagger_ui.status_code == 200 - spec_json_filename = '/v1.0/{spec}'.format( - spec=spec.replace("yaml", "json")) - assert spec_json_filename.encode() in swagger_ui.data - if "openapi" in spec: - assert b'swagger-ui-config.json' not in swagger_ui.data - - -@pytest.mark.parametrize("spec", SPECS) -def test_swagger_ui_with_config(simple_api_spec_dir, spec): - swagger_ui_config = {"displayOperationId": True} - options = {"swagger_ui_config": swagger_ui_config} - app = App(__name__, port=5001, specification_dir=simple_api_spec_dir, - options=options, debug=True) - app.add_api(spec) - app_client = app.app.test_client() - swagger_ui = app_client.get('/v1.0/ui/') # type: flask.Response - assert swagger_ui.status_code == 200 - if "openapi" in spec: - assert b'configUrl: "swagger-ui-config.json"' in swagger_ui.data - - -@pytest.mark.parametrize("spec", SPECS) -def test_no_swagger_ui(simple_api_spec_dir, spec): - options = {"swagger_ui": False} - app = App(__name__, port=5001, specification_dir=simple_api_spec_dir, - options=options, debug=True) - app.add_api(spec) - - app_client = app.app.test_client() - swagger_ui = app_client.get('/v1.0/ui/') # type: flask.Response - assert swagger_ui.status_code == 404 - - app2 = App(__name__, port=5001, - specification_dir=simple_api_spec_dir, debug=True) - app2.add_api(spec, options={"swagger_ui": False}) - app2_client = app2.app.test_client() - swagger_ui2 = app2_client.get('/v1.0/ui/') # type: flask.Response - assert swagger_ui2.status_code == 404 - - -@pytest.mark.parametrize("spec", SPECS) -def test_swagger_ui_config_json(simple_api_spec_dir, spec): - """ Verify the swagger-ui-config.json file is returned for swagger_ui_config option passed to app. """ - swagger_ui_config = {"displayOperationId": True} - options = {"swagger_ui_config": swagger_ui_config} - app = App(__name__, port=5001, specification_dir=simple_api_spec_dir, - options=options, debug=True) - app.add_api(spec) - app_client = app.app.test_client() - url = '/v1.0/ui/swagger-ui-config.json' - swagger_ui_config_json = app_client.get(url) # type: flask.Response - assert swagger_ui_config_json.status_code == 200 - assert swagger_ui_config == json.loads( - swagger_ui_config_json.get_data(as_text=True)) - - -@pytest.mark.parametrize("spec", SPECS) -def test_no_swagger_ui_config_json(simple_api_spec_dir, spec): - """ Verify the swagger-ui-config.json file is not returned when the swagger_ui_config option not passed to app. """ - app = App(__name__, port=5001, - specification_dir=simple_api_spec_dir, debug=True) - app.add_api(spec) - app_client = app.app.test_client() - url = '/v1.0/ui/swagger-ui-config.json' - swagger_ui_config_json = app_client.get(url) # type: flask.Response - assert swagger_ui_config_json.status_code == 404 - - -@pytest.mark.parametrize("spec", SPECS) -def test_swagger_json_app(simple_api_spec_dir, spec): - """ Verify the spec json file is returned for default setting passed to app. """ - app = App(__name__, port=5001, - specification_dir=simple_api_spec_dir, debug=True) - app.add_api(spec) - app_client = app.app.test_client() - url = '/v1.0/{spec}' - url = url.format(spec=spec.replace("yaml", "json")) - spec_json = app_client.get(url) # type: flask.Response - assert spec_json.status_code == 200 - - -@pytest.mark.parametrize("spec", SPECS) -def test_swagger_yaml_app(simple_api_spec_dir, spec): - """ Verify the spec yaml file is returned for default setting passed to app. """ - app = App(__name__, port=5001, - specification_dir=simple_api_spec_dir, debug=True) - app.add_api(spec) - app_client = app.app.test_client() - url = '/v1.0/{spec}' - url = url.format(spec=spec) - spec_response = app_client.get(url) # type: flask.Response - assert spec_response.status_code == 200 - - -@pytest.mark.parametrize("spec", SPECS) -def test_no_swagger_json_app(simple_api_spec_dir, spec): - """ Verify the spec json file is not returned when set to False when creating app. """ - options = {"serve_spec": False} - app = App(__name__, port=5001, specification_dir=simple_api_spec_dir, - options=options, debug=True) - app.add_api(spec) - - app_client = app.app.test_client() - url = '/v1.0/{spec}' - url = url.format(spec=spec.replace("yaml", "json")) - spec_json = app_client.get(url) # type: flask.Response - assert spec_json.status_code == 404 - - -@pytest.mark.parametrize("spec", SPECS) -def test_dict_as_yaml_path(simple_api_spec_dir, spec): - openapi_yaml_path = simple_api_spec_dir / spec - - with openapi_yaml_path.open(mode='rb') as openapi_yaml: - contents = openapi_yaml.read() - try: - openapi_template = contents.decode() - except UnicodeDecodeError: - openapi_template = contents.decode('utf-8', 'replace') - - openapi_string = jinja2.Template(openapi_template).render({}) - specification = yaml.load( - openapi_string, ExtendedSafeLoader) # type: dict - - app = App(__name__, port=5001, - specification_dir=simple_api_spec_dir, debug=True) - app.add_api(specification) - - app_client = app.app.test_client() - url = '/v1.0/{spec}'.format(spec=spec.replace("yaml", "json")) - swagger_json = app_client.get(url) # type: flask.Response - assert swagger_json.status_code == 200 - - -@pytest.mark.parametrize("spec", SPECS) -def test_swagger_json_api(simple_api_spec_dir, spec): - """ Verify the spec json file is returned for default setting passed to api. """ - app = App(__name__, port=5001, - specification_dir=simple_api_spec_dir, debug=True) - app.add_api(spec) - - app_client = app.app.test_client() - url = '/v1.0/{spec}'.format(spec=spec.replace("yaml", "json")) - swagger_json = app_client.get(url) # type: flask.Response - assert swagger_json.status_code == 200 - - -@pytest.mark.parametrize("spec", SPECS) -def test_no_swagger_json_api(simple_api_spec_dir, spec): - """ Verify the spec json file is not returned when set to False when adding api. """ - app = App(__name__, port=5001, - specification_dir=simple_api_spec_dir, debug=True) - app.add_api(spec, options={"serve_spec": False}) - - app_client = app.app.test_client() - url = '/v1.0/{spec}'.format(spec=spec.replace("yaml", "json")) - swagger_json = app_client.get(url) # type: flask.Response - assert swagger_json.status_code == 404 - - -def test_swagger_json_content_type(simple_app): - app_client = simple_app.app.test_client() - spec = simple_app._spec_file - url = '/v1.0/{spec}'.format(spec=spec.replace("yaml", "json")) - response = app_client.get(url) # type: flask.Response - assert response.status_code == 200 - assert response.content_type == 'application/json' - - -def test_single_route(simple_app): - def route1(): - return 'single 1' - - @simple_app.route('/single2', methods=['POST']) - def route2(): - return 'single 2' - - app_client = simple_app.app.test_client() - - simple_app.add_url_rule('/single1', 'single1', route1, methods=['GET']) - - get_single1 = app_client.get('/single1') # type: flask.Response - assert get_single1.data == b'single 1' - - post_single1 = app_client.post('/single1') # type: flask.Response - assert post_single1.status_code == 405 - - post_single2 = app_client.post('/single2') # type: flask.Response - assert post_single2.data == b'single 2' - - get_single2 = app_client.get('/single2') # type: flask.Response - assert get_single2.status_code == 405 - - -def test_resolve_method(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.get('/v1.0/resolver-test/method') # type: flask.Response - assert resp.data == b'"DummyClass"\n' - - -def test_resolve_classmethod(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.get('/v1.0/resolver-test/classmethod') # type: flask.Response - assert resp.data.decode('utf-8', 'replace') == '"DummyClass"\n' - - -@pytest.mark.parametrize("spec", SPECS) -def test_add_api_with_function_resolver_function_is_wrapped(simple_api_spec_dir, spec): - app = App(__name__, specification_dir=simple_api_spec_dir) - api = app.add_api(spec, resolver=lambda oid: (lambda foo: 'bar')) - assert api.resolver.resolve_function_from_operation_id( - 'faux')('bah') == 'bar' - - -def test_default_query_param_does_not_match_defined_type( - default_param_error_spec_dir): - with pytest.raises(InvalidSpecification): - build_app_from_fixture(default_param_error_spec_dir, - validate_responses=True, debug=False) - - -def test_handle_add_operation_error_debug(simple_api_spec_dir): - app = App(__name__, specification_dir=simple_api_spec_dir, debug=True) - app.api_cls = type('AppTest', (app.api_cls,), {}) - app.api_cls.add_operation = mock.MagicMock( - side_effect=Exception('operation error!')) - api = app.add_api('swagger.yaml', resolver=lambda oid: (lambda foo: 'bar')) - assert app.api_cls.add_operation.called - assert api.resolver.resolve_function_from_operation_id( - 'faux')('bah') == 'bar' - - -def test_handle_add_operation_error(simple_api_spec_dir): - app = App(__name__, specification_dir=simple_api_spec_dir) - app.api_cls = type('AppTest', (app.api_cls,), {}) - app.api_cls.add_operation = mock.MagicMock( - side_effect=Exception('operation error!')) - with pytest.raises(Exception): - app.add_api('swagger.yaml', resolver=lambda oid: (lambda foo: 'bar')) - - -def test_using_all_fields_in_path_item(simple_api_spec_dir): - """Test that firetail will try to add an endpoint only on http methods. - - test also that each http methods has its own endpoint. - """ - app = App(__name__, specification_dir=simple_api_spec_dir) - app.add_api('openapi.yaml') - - test_methods = set() - for rule in app.app.url_map.iter_rules(): - if rule.rule != "/v1.0/add_operation_on_http_methods_only": - continue - test_methods.update({method.lower() for method in rule.methods}) - assert set(test_methods) == METHODS +import json +from unittest import mock + +import jinja2 +import pytest +import yaml +from conftest import TEST_FOLDER, build_app_from_fixture +from firetail import App +from firetail.exceptions import InvalidSpecification +from firetail.http_facts import METHODS +from firetail.json_schema import ExtendedSafeLoader + +SPECS = ["swagger.yaml", "openapi.yaml"] + + +@pytest.mark.parametrize("spec", SPECS) +def test_app_with_relative_path(simple_api_spec_dir, spec): + # Create the app with a relative path and run the test_app testcase below. + app = App(__name__, port=5001, + specification_dir='..' / simple_api_spec_dir.relative_to(TEST_FOLDER), + debug=True) + app.add_api(spec) + + app_client = app.app.test_client() + get_bye = app_client.get('/v1.0/bye/jsantos') # type: flask.Response + assert get_bye.status_code == 200 + assert get_bye.data == b'Goodbye jsantos' + + +@pytest.mark.parametrize("spec", SPECS) +def test_app_with_resolver(simple_api_spec_dir, spec): + from firetail.resolver import Resolver + resolver = Resolver() + app = App(__name__, port=5001, + specification_dir='..' / simple_api_spec_dir.relative_to(TEST_FOLDER), + resolver=resolver) + api = app.add_api(spec) + assert api.resolver is resolver + + +@pytest.mark.parametrize("spec", SPECS) +def test_app_with_different_server_option(simple_api_spec_dir, spec): + # Create the app with a relative path and run the test_app testcase below. + app = App(__name__, port=5001, + server='gevent', + specification_dir='..' / simple_api_spec_dir.relative_to(TEST_FOLDER), + debug=True) + app.add_api(spec) + + app_client = app.app.test_client() + get_bye = app_client.get('/v1.0/bye/jsantos') # type: flask.Response + assert get_bye.status_code == 200 + assert get_bye.data == b'Goodbye jsantos' + + +def test_app_with_different_uri_parser(simple_api_spec_dir): + from firetail.decorators.uri_parsing import FirstValueURIParser + app = App(__name__, port=5001, + specification_dir='..' / simple_api_spec_dir.relative_to(TEST_FOLDER), + options={"uri_parser_class": FirstValueURIParser}, + debug=True) + app.add_api('swagger.yaml') + + app_client = app.app.test_client() + resp = app_client.get( + '/v1.0/test_array_csv_query_param?items=a,b,c&items=d,e,f' + ) # type: flask.Response + assert resp.status_code == 200 + j = json.loads(resp.get_data(as_text=True)) + assert j == ['a', 'b', 'c'] + + +@pytest.mark.parametrize("spec", SPECS) +def test_swagger_ui(simple_api_spec_dir, spec): + app = App(__name__, port=5001, specification_dir=simple_api_spec_dir, debug=True) + app.add_api(spec) + app_client = app.app.test_client() + swagger_ui = app_client.get('/v1.0/ui/') # type: flask.Response + assert swagger_ui.status_code == 200 + spec_json_filename = '/v1.0/{spec}'.format(spec=spec.replace("yaml", "json")) + assert spec_json_filename.encode() in swagger_ui.data + if "openapi" in spec: + assert b'swagger-ui-config.json' not in swagger_ui.data + + +@pytest.mark.parametrize("spec", SPECS) +def test_swagger_ui_with_config(simple_api_spec_dir, spec): + swagger_ui_config = {"displayOperationId": True} + options = {"swagger_ui_config": swagger_ui_config} + app = App(__name__, port=5001, specification_dir=simple_api_spec_dir, + options=options, debug=True) + app.add_api(spec) + app_client = app.app.test_client() + swagger_ui = app_client.get('/v1.0/ui/') # type: flask.Response + assert swagger_ui.status_code == 200 + if "openapi" in spec: + assert b'configUrl: "swagger-ui-config.json"' in swagger_ui.data + + +@pytest.mark.parametrize("spec", SPECS) +def test_no_swagger_ui(simple_api_spec_dir, spec): + options = {"swagger_ui": False} + app = App(__name__, port=5001, specification_dir=simple_api_spec_dir, + options=options, debug=True) + app.add_api(spec) + + app_client = app.app.test_client() + swagger_ui = app_client.get('/v1.0/ui/') # type: flask.Response + assert swagger_ui.status_code == 404 + + app2 = App(__name__, port=5001, specification_dir=simple_api_spec_dir, debug=True) + app2.add_api(spec, options={"swagger_ui": False}) + app2_client = app2.app.test_client() + swagger_ui2 = app2_client.get('/v1.0/ui/') # type: flask.Response + assert swagger_ui2.status_code == 404 + + +@pytest.mark.parametrize("spec", SPECS) +def test_swagger_ui_config_json(simple_api_spec_dir, spec): + """ Verify the swagger-ui-config.json file is returned for swagger_ui_config option passed to app. """ + swagger_ui_config = {"displayOperationId": True} + options = {"swagger_ui_config": swagger_ui_config} + app = App(__name__, port=5001, specification_dir=simple_api_spec_dir, + options=options, debug=True) + app.add_api(spec) + app_client = app.app.test_client() + url = '/v1.0/ui/swagger-ui-config.json' + swagger_ui_config_json = app_client.get(url) # type: flask.Response + assert swagger_ui_config_json.status_code == 200 + assert swagger_ui_config == json.loads(swagger_ui_config_json.get_data(as_text=True)) + + +@pytest.mark.parametrize("spec", SPECS) +def test_no_swagger_ui_config_json(simple_api_spec_dir, spec): + """ Verify the swagger-ui-config.json file is not returned when the swagger_ui_config option not passed to app. """ + app = App(__name__, port=5001, specification_dir=simple_api_spec_dir, debug=True) + app.add_api(spec) + app_client = app.app.test_client() + url = '/v1.0/ui/swagger-ui-config.json' + swagger_ui_config_json = app_client.get(url) # type: flask.Response + assert swagger_ui_config_json.status_code == 404 + + +@pytest.mark.parametrize("spec", SPECS) +def test_swagger_json_app(simple_api_spec_dir, spec): + """ Verify the spec json file is returned for default setting passed to app. """ + app = App(__name__, port=5001, specification_dir=simple_api_spec_dir, debug=True) + app.add_api(spec) + app_client = app.app.test_client() + url = '/v1.0/{spec}' + url = url.format(spec=spec.replace("yaml", "json")) + spec_json = app_client.get(url) # type: flask.Response + assert spec_json.status_code == 200 + + +@pytest.mark.parametrize("spec", SPECS) +def test_swagger_yaml_app(simple_api_spec_dir, spec): + """ Verify the spec yaml file is returned for default setting passed to app. """ + app = App(__name__, port=5001, specification_dir=simple_api_spec_dir, debug=True) + app.add_api(spec) + app_client = app.app.test_client() + url = '/v1.0/{spec}' + url = url.format(spec=spec) + spec_response = app_client.get(url) # type: flask.Response + assert spec_response.status_code == 200 + + +@pytest.mark.parametrize("spec", SPECS) +def test_no_swagger_json_app(simple_api_spec_dir, spec): + """ Verify the spec json file is not returned when set to False when creating app. """ + options = {"serve_spec": False} + app = App(__name__, port=5001, specification_dir=simple_api_spec_dir, + options=options, debug=True) + app.add_api(spec) + + app_client = app.app.test_client() + url = '/v1.0/{spec}' + url = url.format(spec=spec.replace("yaml", "json")) + spec_json = app_client.get(url) # type: flask.Response + assert spec_json.status_code == 404 + + +@pytest.mark.parametrize("spec", SPECS) +def test_dict_as_yaml_path(simple_api_spec_dir, spec): + openapi_yaml_path = simple_api_spec_dir / spec + + with openapi_yaml_path.open(mode='rb') as openapi_yaml: + contents = openapi_yaml.read() + try: + openapi_template = contents.decode() + except UnicodeDecodeError: + openapi_template = contents.decode('utf-8', 'replace') + + openapi_string = jinja2.Template(openapi_template).render({}) + specification = yaml.load(openapi_string, ExtendedSafeLoader) # type: dict + + app = App(__name__, port=5001, specification_dir=simple_api_spec_dir, debug=True) + app.add_api(specification) + + app_client = app.app.test_client() + url = '/v1.0/{spec}'.format(spec=spec.replace("yaml", "json")) + swagger_json = app_client.get(url) # type: flask.Response + assert swagger_json.status_code == 200 + + +@pytest.mark.parametrize("spec", SPECS) +def test_swagger_json_api(simple_api_spec_dir, spec): + """ Verify the spec json file is returned for default setting passed to api. """ + app = App(__name__, port=5001, specification_dir=simple_api_spec_dir, debug=True) + app.add_api(spec) + + app_client = app.app.test_client() + url = '/v1.0/{spec}'.format(spec=spec.replace("yaml", "json")) + swagger_json = app_client.get(url) # type: flask.Response + assert swagger_json.status_code == 200 + + +@pytest.mark.parametrize("spec", SPECS) +def test_no_swagger_json_api(simple_api_spec_dir, spec): + """ Verify the spec json file is not returned when set to False when adding api. """ + app = App(__name__, port=5001, specification_dir=simple_api_spec_dir, debug=True) + app.add_api(spec, options={"serve_spec": False}) + + app_client = app.app.test_client() + url = '/v1.0/{spec}'.format(spec=spec.replace("yaml", "json")) + swagger_json = app_client.get(url) # type: flask.Response + assert swagger_json.status_code == 404 + + +def test_swagger_json_content_type(simple_app): + app_client = simple_app.app.test_client() + spec = simple_app._spec_file + url = '/v1.0/{spec}'.format(spec=spec.replace("yaml", "json")) + response = app_client.get(url) # type: flask.Response + assert response.status_code == 200 + assert response.content_type == 'application/json' + + +def test_single_route(): + app = App(__name__) + + def route1(): + return 'single 1' + + @app.route('/single2', methods=['POST']) + def route2(): + return 'single 2' + + app_client = app.app.test_client() + + app.add_url_rule('/single1', 'single1', route1, methods=['GET']) + + get_single1 = app_client.get('/single1') # type: flask.Response + assert get_single1.data == b'single 1' + + post_single1 = app_client.post('/single1') # type: flask.Response + assert post_single1.status_code == 405 + + post_single2 = app_client.post('/single2') # type: flask.Response + assert post_single2.data == b'single 2' + + get_single2 = app_client.get('/single2') # type: flask.Response + assert get_single2.status_code == 405 + + +def test_resolve_method(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/resolver-test/method') # type: flask.Response + assert resp.data == b'"DummyClass"\n' + + +def test_resolve_classmethod(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/resolver-test/classmethod') # type: flask.Response + assert resp.data.decode('utf-8', 'replace') == '"DummyClass"\n' + + +@pytest.mark.parametrize("spec", SPECS) +def test_add_api_with_function_resolver_function_is_wrapped(simple_api_spec_dir, spec): + app = App(__name__, specification_dir=simple_api_spec_dir) + api = app.add_api(spec, resolver=lambda oid: (lambda foo: 'bar')) + assert api.resolver.resolve_function_from_operation_id('faux')('bah') == 'bar' + + +def test_default_query_param_does_not_match_defined_type( + default_param_error_spec_dir): + with pytest.raises(InvalidSpecification): + build_app_from_fixture(default_param_error_spec_dir, validate_responses=True, debug=False) + + +def test_handle_add_operation_error_debug(simple_api_spec_dir): + app = App(__name__, specification_dir=simple_api_spec_dir, debug=True) + app.api_cls = type('AppTest', (app.api_cls,), {}) + app.api_cls.add_operation = mock.MagicMock(side_effect=Exception('operation error!')) + api = app.add_api('swagger.yaml', resolver=lambda oid: (lambda foo: 'bar')) + assert app.api_cls.add_operation.called + assert api.resolver.resolve_function_from_operation_id('faux')('bah') == 'bar' + + +def test_handle_add_operation_error(simple_api_spec_dir): + app = App(__name__, specification_dir=simple_api_spec_dir) + app.api_cls = type('AppTest', (app.api_cls,), {}) + app.api_cls.add_operation = mock.MagicMock(side_effect=Exception('operation error!')) + with pytest.raises(Exception): + app.add_api('swagger.yaml', resolver=lambda oid: (lambda foo: 'bar')) + + +def test_using_all_fields_in_path_item(simple_api_spec_dir): + """Test that firetail will try to add an endpoint only on http methods. + + test also that each http methods has its own endpoint. + """ + app = App(__name__, specification_dir=simple_api_spec_dir) + app.add_api('openapi.yaml') + + test_methods = set() + for rule in app.app.url_map.iter_rules(): + if rule.rule != "/v1.0/add_operation_on_http_methods_only": + continue + test_methods.update({method.lower() for method in rule.methods}) + assert set(test_methods) == METHODS diff --git a/tests/api/test_errors.py b/tests/api/test_errors.py index dc91dca..1ad2a8f 100644 --- a/tests/api/test_errors.py +++ b/tests/api/test_errors.py @@ -1,88 +1,88 @@ -import json - -import flask - - -def fix_data(data): - return data.replace(b'\\"', b'"') - - -def test_errors(problem_app): - app_client = problem_app.app.test_client() - - greeting404 = app_client.get('/v1.0/greeting') # type: flask.Response - assert greeting404.content_type == 'application/problem+json' - assert greeting404.status_code == 404 - error404 = flask.json.loads(fix_data(greeting404.data)) - assert error404['type'] == 'about:blank' - assert error404['title'] == 'Not Found' - assert error404['detail'] == 'The requested URL was not found on the server. ' \ - 'If you entered the URL manually please check your spelling and try again.' - assert error404['status'] == 404 - assert 'instance' not in error404 - - get_greeting = app_client.get('/v1.0/greeting/jsantos') # type: flask.Response - assert get_greeting.content_type == 'application/problem+json' - assert get_greeting.status_code == 405 - error405 = json.loads(get_greeting.data.decode('utf-8', 'replace')) - assert error405['type'] == 'about:blank' - assert error405['title'] == 'Method Not Allowed' - assert error405['detail'] == 'The method is not allowed for the requested URL.' - assert error405['status'] == 405 - assert 'instance' not in error405 - - get500 = app_client.get('/v1.0/except') # type: flask.Response - assert get500.content_type == 'application/problem+json' - assert get500.status_code == 500 - error500 = json.loads(get500.data.decode('utf-8', 'replace')) - assert error500['type'] == 'about:blank' - assert error500['title'] == 'Internal Server Error' - assert error500['detail'] == 'The server encountered an internal error and was unable to complete your request. ' \ - 'Either the server is overloaded or there is an error in the application.' - assert error500['status'] == 500 - assert 'instance' not in error500 - - get_problem = app_client.get('/v1.0/problem') # type: flask.Response - assert get_problem.content_type == 'application/problem+json' - assert get_problem.status_code == 402 - assert get_problem.headers['x-Test-Header'] == 'In Test' - error_problem = json.loads(get_problem.data.decode('utf-8', 'replace')) - assert error_problem['type'] == 'http://www.example.com/error' - assert error_problem['title'] == 'Some Error' - assert error_problem['detail'] == 'Something went wrong somewhere' - assert error_problem['status'] == 402 - assert error_problem['instance'] == 'instance1' - - get_problem2 = app_client.get('/v1.0/other_problem') # type: flask.Response - assert get_problem2.content_type == 'application/problem+json' - assert get_problem2.status_code == 402 - error_problem2 = json.loads(get_problem2.data.decode('utf-8', 'replace')) - assert error_problem2['type'] == 'about:blank' - assert error_problem2['title'] == 'Some Error' - assert error_problem2['detail'] == 'Something went wrong somewhere' - assert error_problem2['status'] == 402 - assert error_problem2['instance'] == 'instance1' - - problematic_json = app_client.get( - '/v1.0/json_response_with_undefined_value_to_serialize') # type: flask.Response - assert problematic_json.status_code == 500 - - custom_problem = app_client.get('/v1.0/customized_problem_response') - assert custom_problem.status_code == 403 - problem_body = json.loads(custom_problem.data.decode('utf-8', 'replace')) - assert 'amount' in problem_body - assert problem_body['amount'] == 23. - - problem_as_exception = app_client.get('/v1.0/problem_exception_with_extra_args') - assert problem_as_exception.status_code == 400 - problem_as_exception_body = json.loads(problem_as_exception.data.decode('utf-8', 'replace')) - assert 'age' in problem_as_exception_body - assert problem_as_exception_body['age'] == 30 - - unsupported_media_type = app_client.post('/v1.0/post_wrong_content_type', data='', content_type='text/html') - assert unsupported_media_type.status_code == 415 - unsupported_media_type_body = json.loads(unsupported_media_type.data.decode('utf-8', 'replace')) - assert unsupported_media_type_body['type'] == 'about:blank' - assert unsupported_media_type_body['title'] == 'Unsupported Media Type' - assert unsupported_media_type_body['detail'] == 'Invalid Content-type (text/html), expected JSON data' - assert unsupported_media_type_body['status'] == 415 +import json + +import flask + + +def fix_data(data): + return data.replace(b'\\"', b'"') + + +def test_errors(problem_app): + app_client = problem_app.app.test_client() + + greeting404 = app_client.get('/v1.0/greeting') # type: flask.Response + assert greeting404.content_type == 'application/problem+json' + assert greeting404.status_code == 404 + error404 = flask.json.loads(fix_data(greeting404.data)) + assert error404['type'] == 'about:blank' + assert error404['title'] == 'Not Found' + assert error404['detail'] == 'The requested URL was not found on the server. ' \ + 'If you entered the URL manually please check your spelling and try again.' + assert error404['status'] == 404 + assert 'instance' not in error404 + + get_greeting = app_client.get('/v1.0/greeting/jsantos') # type: flask.Response + assert get_greeting.content_type == 'application/problem+json' + assert get_greeting.status_code == 405 + error405 = json.loads(get_greeting.data.decode('utf-8', 'replace')) + assert error405['type'] == 'about:blank' + assert error405['title'] == 'Method Not Allowed' + assert error405['detail'] == 'The method is not allowed for the requested URL.' + assert error405['status'] == 405 + assert 'instance' not in error405 + + get500 = app_client.get('/v1.0/except') # type: flask.Response + assert get500.content_type == 'application/problem+json' + assert get500.status_code == 500 + error500 = json.loads(get500.data.decode('utf-8', 'replace')) + assert error500['type'] == 'about:blank' + assert error500['title'] == 'Internal Server Error' + assert error500['detail'] == 'The server encountered an internal error and was unable to complete your request. ' \ + 'Either the server is overloaded or there is an error in the application.' + assert error500['status'] == 500 + assert 'instance' not in error500 + + get_problem = app_client.get('/v1.0/problem') # type: flask.Response + assert get_problem.content_type == 'application/problem+json' + assert get_problem.status_code == 418 + assert get_problem.headers['x-Test-Header'] == 'In Test' + error_problem = json.loads(get_problem.data.decode('utf-8', 'replace')) + assert error_problem['type'] == 'http://www.example.com/error' + assert error_problem['title'] == 'Some Error' + assert error_problem['detail'] == 'Something went wrong somewhere' + assert error_problem['status'] == 418 + assert error_problem['instance'] == 'instance1' + + get_problem2 = app_client.get('/v1.0/other_problem') # type: flask.Response + assert get_problem2.content_type == 'application/problem+json' + assert get_problem2.status_code == 418 + error_problem2 = json.loads(get_problem2.data.decode('utf-8', 'replace')) + assert error_problem2['type'] == 'about:blank' + assert error_problem2['title'] == 'Some Error' + assert error_problem2['detail'] == 'Something went wrong somewhere' + assert error_problem2['status'] == 418 + assert error_problem2['instance'] == 'instance1' + + problematic_json = app_client.get( + '/v1.0/json_response_with_undefined_value_to_serialize') # type: flask.Response + assert problematic_json.status_code == 500 + + custom_problem = app_client.get('/v1.0/customized_problem_response') + assert custom_problem.status_code == 403 + problem_body = json.loads(custom_problem.data.decode('utf-8', 'replace')) + assert 'amount' in problem_body + assert problem_body['amount'] == 23. + + problem_as_exception = app_client.get('/v1.0/problem_exception_with_extra_args') + assert problem_as_exception.status_code == 400 + problem_as_exception_body = json.loads(problem_as_exception.data.decode('utf-8', 'replace')) + assert 'age' in problem_as_exception_body + assert problem_as_exception_body['age'] == 30 + + unsupported_media_type = app_client.post('/v1.0/post_wrong_content_type', data='', content_type='text/html') + assert unsupported_media_type.status_code == 415 + unsupported_media_type_body = json.loads(unsupported_media_type.data.decode('utf-8', 'replace')) + assert unsupported_media_type_body['type'] == 'about:blank' + assert unsupported_media_type_body['title'] == 'Unsupported Media Type' + assert unsupported_media_type_body['detail'] == 'Invalid Content-type (text/html), expected JSON data' + assert unsupported_media_type_body['status'] == 415 diff --git a/tests/api/test_headers.py b/tests/api/test_headers.py index f8be21d..87cdafa 100644 --- a/tests/api/test_headers.py +++ b/tests/api/test_headers.py @@ -1,53 +1,53 @@ -import json - - -def test_headers_jsonifier(simple_app): - app_client = simple_app.app.test_client() - - response = app_client.post('/v1.0/goodday/dan', data={}) # type: flask.Response - assert response.status_code == 201 - # Default Werkzeug behavior was changed in 2.1 (https://github.com/pallets/werkzeug/issues/2352) - assert response.headers["Location"] in ["http://localhost/my/uri", "/my/uri"] - - -def test_headers_produces(simple_app): - app_client = simple_app.app.test_client() - - response = app_client.post('/v1.0/goodevening/dan', data={}) # type: flask.Response - assert response.status_code == 201 - # Default Werkzeug behavior was changed in 2.1 (https://github.com/pallets/werkzeug/issues/2352) - assert response.headers["Location"] in ["http://localhost/my/uri", "/my/uri"] - - -def test_header_not_returned(simple_openapi_app): - app_client = simple_openapi_app.app.test_client() - - response = app_client.post('/v1.0/goodday/noheader', data={}) # type: flask.Response - assert response.status_code == 500 # view_func has not returned what was promised in spec - assert response.content_type == 'application/problem+json' - data = json.loads(response.data.decode('utf-8', 'replace')) - assert data['type'] == 'about:blank' - assert data['title'] == 'Response headers do not conform to specification' - assert data['detail'] == "Keys in header don't match response specification. Difference: Location" - assert data['status'] == 500 - - -def test_no_content_response_have_headers(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.get('/v1.0/test-204-with-headers') - assert resp.status_code == 204 - assert 'X-Something' in resp.headers - - -def test_no_content_object_and_have_headers(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.get('/v1.0/test-204-with-headers-nocontent-obj') - assert resp.status_code == 204 - assert 'X-Something' in resp.headers - - -def test_optional_header(simple_openapi_app): - app_client = simple_openapi_app.app.test_client() - resp = app_client.get('/v1.0/test-optional-headers') - assert resp.status_code == 200 - assert 'X-Optional-Header' not in resp.headers +import json + + +def test_headers_jsonifier(simple_app): + app_client = simple_app.app.test_client() + + response = app_client.post('/v1.0/goodday/dan', data={}) # type: flask.Response + assert response.status_code == 201 + # Default Werkzeug behavior was changed in 2.1 (https://github.com/pallets/werkzeug/issues/2352) + assert response.headers["Location"] in ["http://localhost/my/uri", "/my/uri"] + + +def test_headers_produces(simple_app): + app_client = simple_app.app.test_client() + + response = app_client.post('/v1.0/goodevening/dan', data={}) # type: flask.Response + assert response.status_code == 201 + # Default Werkzeug behavior was changed in 2.1 (https://github.com/pallets/werkzeug/issues/2352) + assert response.headers["Location"] in ["http://localhost/my/uri", "/my/uri"] + + +def test_header_not_returned(simple_openapi_app): + app_client = simple_openapi_app.app.test_client() + + response = app_client.post('/v1.0/goodday/noheader', data={}) # type: flask.Response + assert response.status_code == 500 # view_func has not returned what was promised in spec + assert response.content_type == 'application/problem+json' + data = json.loads(response.data.decode('utf-8', 'replace')) + assert data['type'] == 'about:blank' + assert data['title'] == 'Response headers do not conform to specification' + assert data['detail'] == "Keys in header don't match response specification. Difference: Location" + assert data['status'] == 500 + + +def test_no_content_response_have_headers(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/test-204-with-headers') + assert resp.status_code == 204 + assert 'X-Something' in resp.headers + + +def test_no_content_object_and_have_headers(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/test-204-with-headers-nocontent-obj') + assert resp.status_code == 204 + assert 'X-Something' in resp.headers + + +def test_optional_header(simple_openapi_app): + app_client = simple_openapi_app.app.test_client() + resp = app_client.get('/v1.0/test-optional-headers') + assert resp.status_code == 200 + assert 'X-Optional-Header' not in resp.headers diff --git a/tests/api/test_parameters.py b/tests/api/test_parameters.py index 3c0a059..7f0ae13 100644 --- a/tests/api/test_parameters.py +++ b/tests/api/test_parameters.py @@ -1,558 +1,519 @@ -import json -from io import BytesIO -from typing import List - -import pytest - - -def test_parameter_validation(simple_app): - app_client = simple_app.app.test_client() - - url = '/v1.0/test_parameter_validation' - - response = app_client.get( - url, query_string={'date': '2015-08-26'}) # type: flask.Response - assert response.status_code == 200 - - for invalid_int in '', 'foo', '0.1': - response = app_client.get( - url, query_string={'int': invalid_int}) # type: flask.Response - assert response.status_code == 400 - - response = app_client.get( - url, query_string={'int': '123'}) # type: flask.Response - assert response.status_code == 200 - - for invalid_bool in '', 'foo', 'yes': - response = app_client.get( - url, query_string={'bool': invalid_bool}) # type: flask.Response - assert response.status_code == 400 - - response = app_client.get( - url, query_string={'bool': 'true'}) # type: flask.Response - assert response.status_code == 200 - - -def test_required_query_param(simple_app): - app_client = simple_app.app.test_client() - - url = '/v1.0/test_required_query_param' - response = app_client.get(url) - assert response.status_code == 400 - - response = app_client.get(url, query_string={'n': '1.23'}) - assert response.status_code == 200 - - -def test_array_query_param(simple_app): - app_client = simple_app.app.test_client() - headers = {'Content-type': 'application/json'} - url = '/v1.0/test_array_csv_query_param' - response = app_client.get(url, headers=headers) - array_response: List[str] = json.loads( - response.data.decode('utf-8', 'replace')) - assert array_response == ['squash', 'banana'] - url = '/v1.0/test_array_csv_query_param?items=one,two,three' - response = app_client.get(url, headers=headers) - array_response: List[str] = json.loads( - response.data.decode('utf-8', 'replace')) - assert array_response == ['one', 'two', 'three'] - url = '/v1.0/test_array_pipes_query_param?items=1|2|3' - response = app_client.get(url, headers=headers) - array_response: List[int] = json.loads( - response.data.decode('utf-8', 'replace')) - assert array_response == [1, 2, 3] - url = '/v1.0/test_array_unsupported_query_param?items=1;2;3' - response = app_client.get(url, headers=headers) - array_response: List[str] = json.loads(response.data.decode( - 'utf-8', 'replace')) # unsupported collectionFormat - assert array_response == ["1;2;3"] - url = '/v1.0/test_array_csv_query_param?items=A&items=B&items=C&items=D,E,F' - response = app_client.get(url, headers=headers) - array_response: List[str] = json.loads(response.data.decode( - 'utf-8', 'replace')) # multi array with csv format - assert array_response == ['D', 'E', 'F'] - url = '/v1.0/test_array_multi_query_param?items=A&items=B&items=C&items=D,E,F' - response = app_client.get(url, headers=headers) - array_response: List[str] = json.loads(response.data.decode( - 'utf-8', 'replace')) # multi array with csv format - assert array_response == ['A', 'B', 'C', 'D', 'E', 'F'] - url = '/v1.0/test_array_pipes_query_param?items=4&items=5&items=6&items=7|8|9' - response = app_client.get(url, headers=headers) - array_response: List[int] = json.loads(response.data.decode( - 'utf-8', 'replace')) # multi array with pipes format - assert array_response == [7, 8, 9] - - -def test_array_form_param(simple_app): - app_client = simple_app.app.test_client() - headers = {'Content-type': 'application/x-www-form-urlencoded'} - url = '/v1.0/test_array_csv_form_param' - response = app_client.post(url, headers=headers) - array_response: List[str] = json.loads( - response.data.decode('utf-8', 'replace')) - assert array_response == ['squash', 'banana'] - url = '/v1.0/test_array_csv_form_param' - response = app_client.post(url, headers=headers, data={ - "items": "one,two,three"}) - array_response: List[str] = json.loads( - response.data.decode('utf-8', 'replace')) - assert array_response == ['one', 'two', 'three'] - url = '/v1.0/test_array_pipes_form_param' - response = app_client.post(url, headers=headers, data={"items": "1|2|3"}) - array_response: List[int] = json.loads( - response.data.decode('utf-8', 'replace')) - assert array_response == [1, 2, 3] - url = '/v1.0/test_array_csv_form_param' - data = 'items=A&items=B&items=C&items=D,E,F' - response = app_client.post(url, headers=headers, data=data) - array_response: List[str] = json.loads(response.data.decode( - 'utf-8', 'replace')) # multi array with csv format - assert array_response == ['D', 'E', 'F'] - url = '/v1.0/test_array_pipes_form_param' - data = 'items=4&items=5&items=6&items=7|8|9' - response = app_client.post(url, headers=headers, data=data) - array_response: List[int] = json.loads(response.data.decode( - 'utf-8', 'replace')) # multi array with pipes format - assert array_response == [7, 8, 9] - - -def test_extra_query_param(simple_app): - app_client = simple_app.app.test_client() - headers = {'Content-type': 'application/json'} - url = '/v1.0/test_parameter_validation?extra_parameter=true' - resp = app_client.get(url, headers=headers) - assert resp.status_code == 200 - - -def test_strict_extra_query_param(strict_app): - app_client = strict_app.app.test_client() - headers = {'Content-type': 'application/json'} - url = '/v1.0/test_parameter_validation?extra_parameter=true' - resp = app_client.get(url, headers=headers) - assert resp.status_code == 400 - response = json.loads(resp.data.decode('utf-8', 'replace')) - assert response['detail'] == "Extra query parameter(s) extra_parameter not in spec" - - -def test_strict_formdata_param(strict_app): - app_client = strict_app.app.test_client() - headers = {'Content-type': 'application/x-www-form-urlencoded'} - url = '/v1.0/test_array_csv_form_param' - resp = app_client.post(url, headers=headers, data={"items": "mango"}) - response = json.loads(resp.data.decode('utf-8', 'replace')) - assert response == ['mango'] - assert resp.status_code == 200 - - -@pytest.mark.parametrize('arg, result', [ - # The cases accepted by the Flask/Werkzeug converter - ['123', 'int 123'], - ['0', 'int 0'], - ['0000', 'int 0'], - # Additional cases that we want to support - ['+123', 'int 123'], - ['+0', 'int 0'], - ['-0', 'int 0'], - ['-123', 'int -123'], -]) -def test_path_parameter_someint(simple_app, arg, result): - assert isinstance(arg, str) # sanity check - app_client = simple_app.app.test_client() - resp = app_client.get(f'/v1.0/test-int-path/{arg}') # type: flask.Response - assert resp.data.decode('utf-8', 'replace') == f'"{result}"\n' - - -def test_path_parameter_someint__bad(simple_app): - # non-integer values will not match Flask route - app_client = simple_app.app.test_client() - resp = app_client.get('/v1.0/test-int-path/foo') # type: flask.Response - assert resp.status_code == 404 - - -@pytest.mark.parametrize('arg, result', [ - # The cases accepted by the Flask/Werkzeug converter - ['123.45', 'float 123.45'], - ['123.0', 'float 123'], - ['0.999999999999999999', 'float 1'], - # Additional cases that we want to support - ['+123.45', 'float 123.45'], - ['-123.45', 'float -123.45'], - ['123.', 'float 123'], - ['.45', 'float 0.45'], - ['123', 'float 123'], - ['0', 'float 0'], - ['0000', 'float 0'], - ['-0.000000001', 'float -1e-09'], - ['100000000000', 'float 1e+11'], -]) -def test_path_parameter_somefloat(simple_app, arg, result): - assert isinstance(arg, str) # sanity check - app_client = simple_app.app.test_client() - resp = app_client.get(f'/v1.0/test-float-path/{arg}') # type: flask.Response - assert resp.data.decode('utf-8', 'replace') == f'"{result}"\n' - - -def test_default_param(strict_app): - app_client = strict_app.app.test_client() - resp = app_client.get('/v1.0/test-default-query-parameter') - assert resp.status_code == 200 - response = json.loads(resp.data.decode('utf-8', 'replace')) - assert response['app_name'] == 'firetail' - - -def test_falsy_param(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.get('/v1.0/test-falsy-param', query_string={'falsy': 0}) - assert resp.status_code == 200 - response = json.loads(resp.data.decode('utf-8', 'replace')) - assert response == 0 - - resp = app_client.get('/v1.0/test-falsy-param') - assert resp.status_code == 200 - response = json.loads(resp.data.decode('utf-8', 'replace')) - assert response == 1 - - -def test_formdata_param(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.post('/v1.0/test-formData-param', - data={'formData': 'test'}) - assert resp.status_code == 200 - response = json.loads(resp.data.decode('utf-8', 'replace')) - assert response == 'test' - - -def test_formdata_bad_request(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.post('/v1.0/test-formData-param') - assert resp.status_code == 400 - response = json.loads(resp.data.decode('utf-8', 'replace')) - assert response['detail'] in [ - "Missing formdata parameter 'formData'", - "'formData' is a required property" # OAS3 - ] - - -def test_formdata_missing_param(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.post('/v1.0/test-formData-missing-param', - data={'missing_formData': 'test'}) - assert resp.status_code == 200 - - -def test_formdata_extra_param(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.post('/v1.0/test-formData-param', - data={'formData': 'test', - 'extra_formData': 'test'}) - assert resp.status_code == 200 - - -def test_strict_formdata_extra_param(strict_app): - app_client = strict_app.app.test_client() - resp = app_client.post('/v1.0/test-formData-param', - data={'formData': 'test', - 'extra_formData': 'test'}) - assert resp.status_code == 400 - response = json.loads(resp.data.decode('utf-8', 'replace')) - assert response['detail'] == "Extra formData parameter(s) extra_formData not in spec" - - -def test_formdata_file_upload(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.post('/v1.0/test-formData-file-upload', - data={'formData': (BytesIO(b'file contents'), 'filename.txt')}) - assert resp.status_code == 200 - response = json.loads(resp.data.decode('utf-8', 'replace')) - assert response == {'filename.txt': 'file contents'} - - -def test_formdata_file_upload_bad_request(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.post('/v1.0/test-formData-file-upload') - assert resp.status_code == 400 - response = json.loads(resp.data.decode('utf-8', 'replace')) - assert response['detail'] in [ - "Missing formdata parameter 'formData'", - "'formData' is a required property" # OAS3 - ] - - -def test_formdata_file_upload_missing_param(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.post('/v1.0/test-formData-file-upload-missing-param', - data={'missing_formData': (BytesIO(b'file contents'), 'example.txt')}) - assert resp.status_code == 200 - - -def test_body_not_allowed_additional_properties(simple_app): - app_client = simple_app.app.test_client() - body = {'body1': 'bodyString', 'additional_property': 'test1'} - resp = app_client.post( - '/v1.0/body-not-allowed-additional-properties', - data=json.dumps(body), - headers={'Content-Type': 'application/json'}) - assert resp.status_code == 400 - - response = json.loads(resp.data.decode('utf-8', 'replace')) - assert 'Additional properties are not allowed' in response['detail'] - - -def test_bool_as_default_param(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.get('/v1.0/test-bool-param') - assert resp.status_code == 200 - - resp = app_client.get('/v1.0/test-bool-param', - query_string={'thruthiness': True}) - assert resp.status_code == 200 - response = json.loads(resp.data.decode('utf-8', 'replace')) - assert response is True - - -def test_bool_param(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.get('/v1.0/test-bool-param', - query_string={'thruthiness': True}) - assert resp.status_code == 200 - response = json.loads(resp.data.decode('utf-8', 'replace')) - assert response is True - - resp = app_client.get('/v1.0/test-bool-param', - query_string={'thruthiness': False}) - assert resp.status_code == 200 - response = json.loads(resp.data.decode('utf-8', 'replace')) - assert response is False - - -def test_bool_array_param(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.get( - '/v1.0/test-bool-array-param?thruthiness=true,true,true') - assert resp.status_code == 200 - response = json.loads(resp.data.decode('utf-8', 'replace')) - assert response is True - - app_client = simple_app.app.test_client() - resp = app_client.get( - '/v1.0/test-bool-array-param?thruthiness=true,true,false') - assert resp.status_code == 200 - response = json.loads(resp.data.decode('utf-8', 'replace')) - assert response is False - - app_client = simple_app.app.test_client() - resp = app_client.get('/v1.0/test-bool-array-param') - assert resp.status_code == 200 - - -def test_required_param_miss_config(simple_app): - app_client = simple_app.app.test_client() - - resp = app_client.get('/v1.0/test-required-param') - assert resp.status_code == 400 - - resp = app_client.get('/v1.0/test-required-param', - query_string={'simple': 'test'}) - assert resp.status_code == 200 - - resp = app_client.get('/v1.0/test-required-param') - assert resp.status_code == 400 - - -def test_parameters_defined_in_path_level(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.get('/v1.0/parameters-in-root-path?title=nice-get') - assert resp.status_code == 200 - assert json.loads(resp.data.decode('utf-8', 'replace')) == ["nice-get"] - - resp = app_client.get('/v1.0/parameters-in-root-path') - assert resp.status_code == 400 - - -def test_array_in_path(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.get('/v1.0/test-array-in-path/one_item') - assert json.loads(resp.data.decode('utf-8', 'replace')) == ["one_item"] - - resp = app_client.get('/v1.0/test-array-in-path/one_item,another_item') - assert json.loads(resp.data.decode('utf-8', 'replace') - ) == ["one_item", "another_item"] - - -def test_nullable_parameter(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.get('/v1.0/nullable-parameters?time_start=null') - assert json.loads(resp.data.decode('utf-8', 'replace')) == 'it was None' - - resp = app_client.get('/v1.0/nullable-parameters?time_start=None') - assert json.loads(resp.data.decode('utf-8', 'replace')) == 'it was None' - - time_start = 1010 - resp = app_client.get( - f'/v1.0/nullable-parameters?time_start={time_start}') - assert json.loads(resp.data.decode('utf-8', 'replace')) == time_start - - resp = app_client.post('/v1.0/nullable-parameters', - data={"post_param": 'None'}) - assert json.loads(resp.data.decode('utf-8', 'replace')) == 'it was None' - - resp = app_client.post('/v1.0/nullable-parameters', - data={"post_param": 'null'}) - assert json.loads(resp.data.decode('utf-8', 'replace')) == 'it was None' - - headers = {"Content-Type": "application/json"} - resp = app_client.put('/v1.0/nullable-parameters', - data="null", headers=headers) - assert json.loads(resp.data.decode('utf-8', 'replace')) == 'it was None' - - resp = app_client.put('/v1.0/nullable-parameters', - data="None", headers=headers) - assert json.loads(resp.data.decode('utf-8', 'replace')) == 'it was None' - - resp = app_client.put('/v1.0/nullable-parameters-noargs', - data="None", headers=headers) - assert json.loads(resp.data.decode('utf-8', 'replace')) == 'hello' - - -def test_args_kwargs(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.get('/v1.0/query-params-as-kwargs') - assert resp.status_code == 200 - assert json.loads(resp.data.decode('utf-8', 'replace')) == {} - - resp = app_client.get('/v1.0/query-params-as-kwargs?foo=a&bar=b') - assert resp.status_code == 200 - assert json.loads(resp.data.decode('utf-8', 'replace')) == {'foo': 'a'} - - if simple_app._spec_file == 'openapi.yaml': - body = {'foo': 'a', 'bar': 'b'} - resp = app_client.post( - '/v1.0/body-params-as-kwargs', - data=json.dumps(body), - headers={'Content-Type': 'application/json'}) - assert resp.status_code == 200 - # having only kwargs, the handler would have been passed 'body' - assert json.loads(resp.data.decode('utf-8', 'replace') - ) == {'body': {'foo': 'a', 'bar': 'b'}, } - - -def test_param_sanitization(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.post('/v1.0/param-sanitization') - assert resp.status_code == 200 - assert json.loads(resp.data.decode('utf-8', 'replace')) == {} - - resp = app_client.post('/v1.0/param-sanitization?$query=queryString', - data={'$form': 'formString'}) - assert resp.status_code == 200 - assert json.loads(resp.data.decode('utf-8', 'replace')) == { - 'query': 'queryString', - 'form': 'formString', - } - - body = {'body1': 'bodyString', 'body2': 'otherString'} - resp = app_client.post( - '/v1.0/body-sanitization', - data=json.dumps(body), - headers={'Content-Type': 'application/json'}) - assert resp.status_code == 200 - assert json.loads(resp.data.decode('utf-8', 'replace')) == body - - body = {'body1': 'bodyString', 'body2': 12, 'body3': {'a': 'otherString'}} - resp = app_client.post( - '/v1.0/body-sanitization-additional-properties', - data=json.dumps(body), - headers={'Content-Type': 'application/json'}) - assert resp.status_code == 200 - assert json.loads(resp.data.decode('utf-8', 'replace')) == body - - body = {'body1': 'bodyString', 'additional_property': 'test1', - 'additional_property2': 'test2'} - resp = app_client.post( - '/v1.0/body-sanitization-additional-properties-defined', - data=json.dumps(body), - headers={'Content-Type': 'application/json'}) - assert resp.status_code == 200 - assert json.loads(resp.data.decode('utf-8', 'replace')) == body - - -def test_no_sanitization_in_request_body(simple_app): - app_client = simple_app.app.test_client() - data = { - 'name': 'John', - '$surname': 'Doe', - '1337': True, - '!#/bin/sh': False, - '(1/0)': 'division by zero', - 's/$/EOL/': 'regular expression', - '@8am': 'time', - } - response = app_client.post('/v1.0/forward', json=data) - - assert response.status_code == 200 - assert response.json == data - - -def test_parameters_snake_case(snake_case_app): - app_client = snake_case_app.app.test_client() - headers = {'Content-type': 'application/json'} - resp = app_client.post('/v1.0/test-post-path-snake/123', - headers=headers, data=json.dumps({"a": "test"})) - assert resp.status_code == 200 - resp = app_client.post('/v1.0/test-post-path-shadow/123', - headers=headers, data=json.dumps({"a": "test"})) - assert resp.status_code == 200 - resp = app_client.post('/v1.0/test-post-query-snake?someId=123', - headers=headers, data=json.dumps({"a": "test"})) - assert resp.status_code == 200 - resp = app_client.post('/v1.0/test-post-query-shadow?id=123&class=header', - headers=headers, data=json.dumps({"a": "test"})) - assert resp.status_code == 200 - resp = app_client.get('/v1.0/test-get-path-snake/123') - assert resp.status_code == 200 - resp = app_client.get('/v1.0/test-get-path-shadow/123') - assert resp.status_code == 200 - resp = app_client.get('/v1.0/test-get-query-snake?someId=123') - assert resp.status_code == 200 - resp = app_client.get('/v1.0/test-get-query-shadow?list=123') - assert resp.status_code == 200 - # Tests for when CamelCase parameter is supplied, of which the snake_case version - # matches an existing parameter and view func argument, or vice versa - resp = app_client.get( - '/v1.0/test-get-camel-case-version?truthiness=true&orderBy=asc') - assert resp.status_code == 200 - assert resp.get_json() == {'truthiness': True, 'order_by': 'asc'} - resp = app_client.get('/v1.0/test-get-camel-case-version?truthiness=5') - assert resp.status_code == 400 - assert resp.get_json()[ - 'detail'] == "Wrong type, expected 'boolean' for query parameter 'truthiness'" - # Incorrectly cased params should be ignored - resp = app_client.get( - '/v1.0/test-get-camel-case-version?Truthiness=true&order_by=asc') - assert resp.status_code == 200 - assert resp.get_json() == {'truthiness': False, - 'order_by': None} # default values - resp = app_client.get( - '/v1.0/test-get-camel-case-version?Truthiness=5&order_by=4') - assert resp.status_code == 200 - assert resp.get_json() == {'truthiness': False, - 'order_by': None} # default values - # TODO: Add tests for body parameters - - -def test_get_unicode_request(simple_app): - """Regression test for Python 2 UnicodeEncodeError bug during parameter parsing.""" - app_client = simple_app.app.test_client() - resp = app_client.get( - '/v1.0/get_unicode_request?price=%C2%A319.99') # £19.99 - assert resp.status_code == 200 - assert json.loads(resp.data.decode('utf-8'))['price'] == '£19.99' - - -def test_cookie_param(simple_app): - app_client = simple_app.app.test_client() - app_client.set_cookie("localhost", "test_cookie", "hello") - response = app_client.get("/v1.0/test-cookie-param") - assert response.status_code == 200 - assert response.json == {"cookie_value": "hello"} +import json +from io import BytesIO +from typing import List + +import pytest + + +def test_parameter_validation(simple_app): + app_client = simple_app.app.test_client() + + url = '/v1.0/test_parameter_validation' + + response = app_client.get(url, query_string={'date': '2015-08-26'}) # type: flask.Response + assert response.status_code == 200 + + for invalid_int in '', 'foo', '0.1': + response = app_client.get(url, query_string={'int': invalid_int}) # type: flask.Response + assert response.status_code == 400 + + response = app_client.get(url, query_string={'int': '123'}) # type: flask.Response + assert response.status_code == 200 + + for invalid_bool in '', 'foo', 'yes': + response = app_client.get(url, query_string={'bool': invalid_bool}) # type: flask.Response + assert response.status_code == 400 + + response = app_client.get(url, query_string={'bool': 'true'}) # type: flask.Response + assert response.status_code == 200 + + +def test_required_query_param(simple_app): + app_client = simple_app.app.test_client() + + url = '/v1.0/test_required_query_param' + response = app_client.get(url) + assert response.status_code == 400 + + response = app_client.get(url, query_string={'n': '1.23'}) + assert response.status_code == 200 + + +def test_array_query_param(simple_app): + app_client = simple_app.app.test_client() + headers = {'Content-type': 'application/json'} + url = '/v1.0/test_array_csv_query_param' + response = app_client.get(url, headers=headers) + array_response: List[str] = json.loads(response.data.decode('utf-8', 'replace')) + assert array_response == ['squash', 'banana'] + url = '/v1.0/test_array_csv_query_param?items=one,two,three' + response = app_client.get(url, headers=headers) + array_response: List[str] = json.loads(response.data.decode('utf-8', 'replace')) + assert array_response == ['one', 'two', 'three'] + url = '/v1.0/test_array_pipes_query_param?items=1|2|3' + response = app_client.get(url, headers=headers) + array_response: List[int] = json.loads(response.data.decode('utf-8', 'replace')) + assert array_response == [1, 2, 3] + url = '/v1.0/test_array_unsupported_query_param?items=1;2;3' + response = app_client.get(url, headers=headers) + array_response: List[str] = json.loads(response.data.decode('utf-8', 'replace')) # unsupported collectionFormat + assert array_response == ["1;2;3"] + url = '/v1.0/test_array_csv_query_param?items=A&items=B&items=C&items=D,E,F' + response = app_client.get(url, headers=headers) + array_response: List[str] = json.loads(response.data.decode('utf-8', 'replace')) # multi array with csv format + assert array_response == ['D', 'E', 'F'] + url = '/v1.0/test_array_multi_query_param?items=A&items=B&items=C&items=D,E,F' + response = app_client.get(url, headers=headers) + array_response: List[str] = json.loads(response.data.decode('utf-8', 'replace')) # multi array with csv format + assert array_response == ['A', 'B', 'C', 'D', 'E', 'F'] + url = '/v1.0/test_array_pipes_query_param?items=4&items=5&items=6&items=7|8|9' + response = app_client.get(url, headers=headers) + array_response: List[int] = json.loads(response.data.decode('utf-8', 'replace')) # multi array with pipes format + assert array_response == [7, 8, 9] + + +def test_array_form_param(simple_app): + app_client = simple_app.app.test_client() + headers = {'Content-type': 'application/x-www-form-urlencoded'} + url = '/v1.0/test_array_csv_form_param' + response = app_client.post(url, headers=headers) + array_response: List[str] = json.loads(response.data.decode('utf-8', 'replace')) + assert array_response == ['squash', 'banana'] + url = '/v1.0/test_array_csv_form_param' + response = app_client.post(url, headers=headers, data={"items": "one,two,three"}) + array_response: List[str] = json.loads(response.data.decode('utf-8', 'replace')) + assert array_response == ['one', 'two', 'three'] + url = '/v1.0/test_array_pipes_form_param' + response = app_client.post(url, headers=headers, data={"items": "1|2|3"}) + array_response: List[int] = json.loads(response.data.decode('utf-8', 'replace')) + assert array_response == [1, 2, 3] + url = '/v1.0/test_array_csv_form_param' + data = 'items=A&items=B&items=C&items=D,E,F' + response = app_client.post(url, headers=headers, data=data) + array_response: List[str] = json.loads(response.data.decode('utf-8', 'replace')) # multi array with csv format + assert array_response == ['D', 'E', 'F'] + url = '/v1.0/test_array_pipes_form_param' + data = 'items=4&items=5&items=6&items=7|8|9' + response = app_client.post(url, headers=headers, data=data) + array_response: List[int] = json.loads(response.data.decode('utf-8', 'replace')) # multi array with pipes format + assert array_response == [7, 8, 9] + + +def test_extra_query_param(simple_app): + app_client = simple_app.app.test_client() + headers = {'Content-type': 'application/json'} + url = '/v1.0/test_parameter_validation?extra_parameter=true' + resp = app_client.get(url, headers=headers) + assert resp.status_code == 200 + + +def test_strict_extra_query_param(strict_app): + app_client = strict_app.app.test_client() + headers = {'Content-type': 'application/json'} + url = '/v1.0/test_parameter_validation?extra_parameter=true' + resp = app_client.get(url, headers=headers) + assert resp.status_code == 400 + response = json.loads(resp.data.decode('utf-8', 'replace')) + assert response['detail'] == "Extra query parameter(s) extra_parameter not in spec" + + +def test_strict_formdata_param(strict_app): + app_client = strict_app.app.test_client() + headers = {'Content-type': 'application/x-www-form-urlencoded'} + url = '/v1.0/test_array_csv_form_param' + resp = app_client.post(url, headers=headers, data={"items":"mango"}) + response = json.loads(resp.data.decode('utf-8', 'replace')) + assert response == ['mango'] + assert resp.status_code == 200 + + +@pytest.mark.parametrize('arg, result', [ + # The cases accepted by the Flask/Werkzeug converter + ['123', 'int 123'], + ['0', 'int 0'], + ['0000', 'int 0'], + # Additional cases that we want to support + ['+123', 'int 123'], + ['+0', 'int 0'], + ['-0', 'int 0'], + ['-123', 'int -123'], +]) +def test_path_parameter_someint(simple_app, arg, result): + assert isinstance(arg, str) # sanity check + app_client = simple_app.app.test_client() + resp = app_client.get(f'/v1.0/test-int-path/{arg}') # type: flask.Response + assert resp.data.decode('utf-8', 'replace') == f'"{result}"\n' + + +def test_path_parameter_someint__bad(simple_app): + # non-integer values will not match Flask route + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/test-int-path/foo') # type: flask.Response + assert resp.status_code == 404 + + +@pytest.mark.parametrize('arg, result', [ + # The cases accepted by the Flask/Werkzeug converter + ['123.45', 'float 123.45'], + ['123.0', 'float 123'], + ['0.999999999999999999', 'float 1'], + # Additional cases that we want to support + ['+123.45', 'float 123.45'], + ['-123.45', 'float -123.45'], + ['123.', 'float 123'], + ['.45', 'float 0.45'], + ['123', 'float 123'], + ['0', 'float 0'], + ['0000', 'float 0'], + ['-0.000000001', 'float -1e-09'], + ['100000000000', 'float 1e+11'], +]) +def test_path_parameter_somefloat(simple_app, arg, result): + assert isinstance(arg, str) # sanity check + app_client = simple_app.app.test_client() + resp = app_client.get(f'/v1.0/test-float-path/{arg}') # type: flask.Response + assert resp.data.decode('utf-8', 'replace') == f'"{result}"\n' + + +def test_path_parameter_somefloat__bad(simple_app): + # non-float values will not match Flask route + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/test-float-path/123,45') # type: flask.Response + assert resp.status_code == 404 + + +def test_default_param(strict_app): + app_client = strict_app.app.test_client() + resp = app_client.get('/v1.0/test-default-query-parameter') + assert resp.status_code == 200 + response = json.loads(resp.data.decode('utf-8', 'replace')) + assert response['app_name'] == 'firetail' + + +def test_falsy_param(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/test-falsy-param', query_string={'falsy': 0}) + assert resp.status_code == 200 + response = json.loads(resp.data.decode('utf-8', 'replace')) + assert response == 0 + + resp = app_client.get('/v1.0/test-falsy-param') + assert resp.status_code == 200 + response = json.loads(resp.data.decode('utf-8', 'replace')) + assert response == 1 + + +def test_formdata_param(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.post('/v1.0/test-formData-param', + data={'formData': 'test'}) + assert resp.status_code == 200 + response = json.loads(resp.data.decode('utf-8', 'replace')) + assert response == 'test' + + +def test_formdata_bad_request(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.post('/v1.0/test-formData-param') + assert resp.status_code == 400 + response = json.loads(resp.data.decode('utf-8', 'replace')) + assert response['detail'] in [ + "Missing formdata parameter 'formData'", + "'formData' is a required property" # OAS3 + ] + + +def test_formdata_missing_param(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.post('/v1.0/test-formData-missing-param', + data={'missing_formData': 'test'}) + assert resp.status_code == 200 + + +def test_formdata_extra_param(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.post('/v1.0/test-formData-param', + data={'formData': 'test', + 'extra_formData': 'test'}) + assert resp.status_code == 200 + + +def test_strict_formdata_extra_param(strict_app): + app_client = strict_app.app.test_client() + resp = app_client.post('/v1.0/test-formData-param', + data={'formData': 'test', + 'extra_formData': 'test'}) + assert resp.status_code == 400 + response = json.loads(resp.data.decode('utf-8', 'replace')) + assert response['detail'] == "Extra formData parameter(s) extra_formData not in spec" + + +def test_formdata_file_upload(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.post('/v1.0/test-formData-file-upload', + data={'formData': (BytesIO(b'file contents'), 'filename.txt')}) + assert resp.status_code == 200 + response = json.loads(resp.data.decode('utf-8', 'replace')) + assert response == {'filename.txt': 'file contents'} + + +def test_formdata_file_upload_bad_request(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.post('/v1.0/test-formData-file-upload') + assert resp.status_code == 400 + response = json.loads(resp.data.decode('utf-8', 'replace')) + assert response['detail'] in [ + "Missing formdata parameter 'formData'", + "'formData' is a required property" # OAS3 + ] + + +def test_formdata_file_upload_missing_param(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.post('/v1.0/test-formData-file-upload-missing-param', + data={'missing_formData': (BytesIO(b'file contents'), 'example.txt')}) + assert resp.status_code == 200 + + +def test_body_not_allowed_additional_properties(simple_app): + app_client = simple_app.app.test_client() + body = { 'body1': 'bodyString', 'additional_property': 'test1'} + resp = app_client.post( + '/v1.0/body-not-allowed-additional-properties', + data=json.dumps(body), + headers={'Content-Type': 'application/json'}) + assert resp.status_code == 400 + + response = json.loads(resp.data.decode('utf-8', 'replace')) + assert 'Additional properties are not allowed' in response['detail'] + +def test_bool_as_default_param(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/test-bool-param') + assert resp.status_code == 200 + + resp = app_client.get('/v1.0/test-bool-param', query_string={'thruthiness': True}) + assert resp.status_code == 200 + response = json.loads(resp.data.decode('utf-8', 'replace')) + assert response is True + + +def test_bool_param(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/test-bool-param', query_string={'thruthiness': True}) + assert resp.status_code == 200 + response = json.loads(resp.data.decode('utf-8', 'replace')) + assert response is True + + resp = app_client.get('/v1.0/test-bool-param', query_string={'thruthiness': False}) + assert resp.status_code == 200 + response = json.loads(resp.data.decode('utf-8', 'replace')) + assert response is False + + +def test_bool_array_param(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/test-bool-array-param?thruthiness=true,true,true') + assert resp.status_code == 200 + response = json.loads(resp.data.decode('utf-8', 'replace')) + assert response is True + + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/test-bool-array-param?thruthiness=true,true,false') + assert resp.status_code == 200 + response = json.loads(resp.data.decode('utf-8', 'replace')) + assert response is False + + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/test-bool-array-param') + assert resp.status_code == 200 + + +def test_required_param_miss_config(simple_app): + app_client = simple_app.app.test_client() + + resp = app_client.get('/v1.0/test-required-param') + assert resp.status_code == 400 + + resp = app_client.get('/v1.0/test-required-param', query_string={'simple': 'test'}) + assert resp.status_code == 200 + + resp = app_client.get('/v1.0/test-required-param') + assert resp.status_code == 400 + + +def test_parameters_defined_in_path_level(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/parameters-in-root-path?title=nice-get') + assert resp.status_code == 200 + assert json.loads(resp.data.decode('utf-8', 'replace')) == ["nice-get"] + + resp = app_client.get('/v1.0/parameters-in-root-path') + assert resp.status_code == 400 + + +def test_array_in_path(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/test-array-in-path/one_item') + assert json.loads(resp.data.decode('utf-8', 'replace')) == ["one_item"] + + resp = app_client.get('/v1.0/test-array-in-path/one_item,another_item') + assert json.loads(resp.data.decode('utf-8', 'replace')) == ["one_item", "another_item"] + + +def test_nullable_parameter(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/nullable-parameters?time_start=null') + assert json.loads(resp.data.decode('utf-8', 'replace')) == 'it was None' + + resp = app_client.get('/v1.0/nullable-parameters?time_start=None') + assert json.loads(resp.data.decode('utf-8', 'replace')) == 'it was None' + + time_start = 1010 + resp = app_client.get( + f'/v1.0/nullable-parameters?time_start={time_start}') + assert json.loads(resp.data.decode('utf-8', 'replace')) == time_start + + resp = app_client.post('/v1.0/nullable-parameters', data={"post_param": 'None'}) + assert json.loads(resp.data.decode('utf-8', 'replace')) == 'it was None' + + resp = app_client.post('/v1.0/nullable-parameters', data={"post_param": 'null'}) + assert json.loads(resp.data.decode('utf-8', 'replace')) == 'it was None' + + headers = {"Content-Type": "application/json"} + resp = app_client.put('/v1.0/nullable-parameters', data="null", headers=headers) + assert json.loads(resp.data.decode('utf-8', 'replace')) == 'it was None' + + resp = app_client.put('/v1.0/nullable-parameters', data="None", headers=headers) + assert json.loads(resp.data.decode('utf-8', 'replace')) == 'it was None' + + resp = app_client.put('/v1.0/nullable-parameters-noargs', data="None", headers=headers) + assert json.loads(resp.data.decode('utf-8', 'replace')) == 'hello' + + +def test_args_kwargs(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/query-params-as-kwargs') + assert resp.status_code == 200 + assert json.loads(resp.data.decode('utf-8', 'replace')) == {} + + resp = app_client.get('/v1.0/query-params-as-kwargs?foo=a&bar=b') + assert resp.status_code == 200 + assert json.loads(resp.data.decode('utf-8', 'replace')) == {'foo': 'a'} + + if simple_app._spec_file == 'openapi.yaml': + body = { 'foo': 'a', 'bar': 'b' } + resp = app_client.post( + '/v1.0/body-params-as-kwargs', + data=json.dumps(body), + headers={'Content-Type': 'application/json'}) + assert resp.status_code == 200 + # having only kwargs, the handler would have been passed 'body' + assert json.loads(resp.data.decode('utf-8', 'replace')) == {'body': {'foo': 'a', 'bar': 'b'}, } + + +def test_param_sanitization(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.post('/v1.0/param-sanitization') + assert resp.status_code == 200 + assert json.loads(resp.data.decode('utf-8', 'replace')) == {} + + resp = app_client.post('/v1.0/param-sanitization?$query=queryString', + data={'$form': 'formString'}) + assert resp.status_code == 200 + assert json.loads(resp.data.decode('utf-8', 'replace')) == { + 'query': 'queryString', + 'form': 'formString', + } + + body = { 'body1': 'bodyString', 'body2': 'otherString' } + resp = app_client.post( + '/v1.0/body-sanitization', + data=json.dumps(body), + headers={'Content-Type': 'application/json'}) + assert resp.status_code == 200 + assert json.loads(resp.data.decode('utf-8', 'replace')) == body + + body = { 'body1': 'bodyString', 'body2': 12, 'body3': {'a':'otherString' }} + resp = app_client.post( + '/v1.0/body-sanitization-additional-properties', + data=json.dumps(body), + headers={'Content-Type': 'application/json'}) + assert resp.status_code == 200 + assert json.loads(resp.data.decode('utf-8', 'replace')) == body + + body = {'body1': 'bodyString', 'additional_property': 'test1', 'additional_property2': 'test2'} + resp = app_client.post( + '/v1.0/body-sanitization-additional-properties-defined', + data=json.dumps(body), + headers={'Content-Type': 'application/json'}) + assert resp.status_code == 200 + assert json.loads(resp.data.decode('utf-8', 'replace')) == body + +def test_no_sanitization_in_request_body(simple_app): + app_client = simple_app.app.test_client() + data = { + 'name': 'John', + '$surname': 'Doe', + '1337': True, + '!#/bin/sh': False, + '(1/0)': 'division by zero', + 's/$/EOL/': 'regular expression', + '@8am': 'time', + } + response = app_client.post('/v1.0/forward', json=data) + + assert response.status_code == 200 + assert response.json == data + +def test_parameters_snake_case(snake_case_app): + app_client = snake_case_app.app.test_client() + headers = {'Content-type': 'application/json'} + resp = app_client.post('/v1.0/test-post-path-snake/123', headers=headers, data=json.dumps({"a": "test"})) + assert resp.status_code == 200 + resp = app_client.post('/v1.0/test-post-path-shadow/123', headers=headers, data=json.dumps({"a": "test"})) + assert resp.status_code == 200 + resp = app_client.post('/v1.0/test-post-query-snake?someId=123', headers=headers, data=json.dumps({"a": "test"})) + assert resp.status_code == 200 + resp = app_client.post('/v1.0/test-post-query-shadow?id=123&class=header', headers=headers, data=json.dumps({"a": "test"})) + assert resp.status_code == 200 + resp = app_client.get('/v1.0/test-get-path-snake/123') + assert resp.status_code == 200 + resp = app_client.get('/v1.0/test-get-path-shadow/123') + assert resp.status_code == 200 + resp = app_client.get('/v1.0/test-get-query-snake?someId=123') + assert resp.status_code == 200 + resp = app_client.get('/v1.0/test-get-query-shadow?list=123') + assert resp.status_code == 200 + # Tests for when CamelCase parameter is supplied, of which the snake_case version + # matches an existing parameter and view func argument, or vice versa + resp = app_client.get('/v1.0/test-get-camel-case-version?truthiness=true&orderBy=asc') + assert resp.status_code == 200 + assert resp.get_json() == {'truthiness': True, 'order_by': 'asc'} + resp = app_client.get('/v1.0/test-get-camel-case-version?truthiness=5') + assert resp.status_code == 400 + assert resp.get_json()['detail'] == "Wrong type, expected 'boolean' for query parameter 'truthiness'" + # Incorrectly cased params should be ignored + resp = app_client.get('/v1.0/test-get-camel-case-version?Truthiness=true&order_by=asc') + assert resp.status_code == 200 + assert resp.get_json() == {'truthiness': False, 'order_by': None} # default values + resp = app_client.get('/v1.0/test-get-camel-case-version?Truthiness=5&order_by=4') + assert resp.status_code == 200 + assert resp.get_json() == {'truthiness': False, 'order_by': None} # default values + # TODO: Add tests for body parameters + + +def test_get_unicode_request(simple_app): + """Regression test for Python 2 UnicodeEncodeError bug during parameter parsing.""" + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/get_unicode_request?price=%C2%A319.99') # £19.99 + assert resp.status_code == 200 + assert json.loads(resp.data.decode('utf-8'))['price'] == '£19.99' + + +def test_cookie_param(simple_app): + app_client = simple_app.app.test_client() + app_client.set_cookie("localhost", "test_cookie", "hello") + response = app_client.get("/v1.0/test-cookie-param") + assert response.status_code == 200 + assert response.json == {"cookie_value": "hello"} diff --git a/tests/api/test_responses.py b/tests/api/test_responses.py index 5c5bb3a..7787639 100644 --- a/tests/api/test_responses.py +++ b/tests/api/test_responses.py @@ -1,408 +1,421 @@ -import json -from struct import unpack - -import yaml -from firetail.apps.flask_app import FlaskJSONEncoder -from werkzeug.test import Client, EnvironBuilder - - -def test_app(simple_app): - assert simple_app.port == 5001 - - app_client = simple_app.app.test_client() - - # by default the Swagger UI is enabled - swagger_ui = app_client.get('/v1.0/ui/') # type: flask.Response - assert swagger_ui.status_code == 200 - assert b"Swagger UI" in swagger_ui.data - - # test return Swagger UI static files - swagger_icon = app_client.get( - '/v1.0/ui/swagger-ui.js') # type: flask.Response - assert swagger_icon.status_code == 200 - - post_greeting_url = app_client.post( - '/v1.0/greeting/jsantos/the/third/of/his/name', data={}) # type: flask.Response - assert post_greeting_url.status_code == 200 - assert post_greeting_url.content_type == 'application/json' - greeting_response_url = json.loads(post_greeting_url.data.decode('utf-8')) - assert greeting_response_url['greeting'] == 'Hello jsantos thanks for the/third/of/his/name' - - post_greeting = app_client.post( - '/v1.0/greeting/jsantos', data={}) # type: flask.Response - assert post_greeting.status_code == 200 - assert post_greeting.content_type == 'application/json' - greeting_response = json.loads(post_greeting.data.decode('utf-8')) - assert greeting_response['greeting'] == 'Hello jsantos' - - get_bye = app_client.get('/v1.0/bye/jsantos') # type: flask.Response - assert get_bye.status_code == 200 - assert get_bye.data == b'Goodbye jsantos' - - post_greeting = app_client.post( - '/v1.0/greeting/jsantos', data={}) # type: flask.Response - assert post_greeting.status_code == 200 - assert post_greeting.content_type == 'application/json' - greeting_response = json.loads(post_greeting.data.decode('utf-8')) - assert greeting_response['greeting'] == 'Hello jsantos' - - -def test_openapi_yaml_behind_proxy(reverse_proxied_app): - """ Verify the swagger.json file is returned with base_path updated - according to X-Original-URI header. - """ - app_client = reverse_proxied_app.app.test_client() - - headers = {'X-Forwarded-Path': '/behind/proxy'} - - swagger_ui = app_client.get('/v1.0/ui/', headers=headers) - assert swagger_ui.status_code == 200 - - openapi_yaml = app_client.get( - '/v1.0/' + reverse_proxied_app._spec_file, - headers=headers - ) - assert openapi_yaml.status_code == 200 - assert openapi_yaml.headers.get('Content-Type').startswith('text/yaml') - spec = yaml.load(openapi_yaml.data.decode('utf-8'), Loader=yaml.BaseLoader) - - if reverse_proxied_app._spec_file == 'swagger.yaml': - assert b'url = "/behind/proxy/v1.0/swagger.json"' in swagger_ui.data - assert spec.get('basePath') == '/behind/proxy/v1.0', \ - "basePath should contains original URI" - else: - assert b'url: "/behind/proxy/v1.0/openapi.json"' in swagger_ui.data - url = spec.get('servers', [{}])[0].get('url') - assert url == '/behind/proxy/v1.0', \ - "basePath should contains original URI" - - -def test_produce_decorator(simple_app): - app_client = simple_app.app.test_client() - - get_bye = app_client.get('/v1.0/bye/jsantos') # type: flask.Response - assert get_bye.content_type == 'text/plain; charset=utf-8' - - -def test_returning_flask_response_tuple(simple_app): - app_client = simple_app.app.test_client() - - result = app_client.get('/v1.0/flask_response_tuple') # type: flask.Response - assert result.status_code == 201 - assert result.content_type == 'application/json' - result_data = json.loads(result.data.decode('utf-8', 'replace')) - assert result_data == {'foo': 'bar'} - - -def test_jsonifier(simple_app): - app_client = simple_app.app.test_client() - - post_greeting = app_client.post( - '/v1.0/greeting/jsantos', data={}) # type: flask.Response - assert post_greeting.status_code == 200 - assert post_greeting.content_type == 'application/json' - greeting_reponse = json.loads( - post_greeting.data.decode('utf-8', 'replace')) - assert greeting_reponse['greeting'] == 'Hello jsantos' - - get_list_greeting = app_client.get( - '/v1.0/list/jsantos', data={}) # type: flask.Response - assert get_list_greeting.status_code == 200 - assert get_list_greeting.content_type == 'application/json' - greeting_reponse = json.loads( - get_list_greeting.data.decode('utf-8', 'replace')) - assert len(greeting_reponse) == 2 - assert greeting_reponse[0] == 'hello' - assert greeting_reponse[1] == 'jsantos' - - get_greetings = app_client.get( - '/v1.0/greetings/jsantos', data={}) # type: flask.Response - assert get_greetings.status_code == 200 - assert get_greetings.content_type == 'application/x.firetail+json' - greetings_reponse = json.loads( - get_greetings.data.decode('utf-8', 'replace')) - assert len(greetings_reponse) == 1 - assert greetings_reponse['greetings'] == 'Hello jsantos' - - -def test_not_content_response(simple_app): - app_client = simple_app.app.test_client() - - get_no_content_response = app_client.get('/v1.0/test_no_content_response') - assert get_no_content_response.status_code == 204 - assert get_no_content_response.content_length is None - - -def test_pass_through(simple_app): - app_client = simple_app.app.test_client() - - response = app_client.get( - '/v1.0/multimime', data={}) # type: flask.Response - assert response.status_code == 200 - - -def test_empty(simple_app): - app_client = simple_app.app.test_client() - - response = app_client.get('/v1.0/empty') # type: flask.Response - assert response.status_code == 204 - assert not response.data - - -def test_exploded_deep_object_param_endpoint_openapi_simple(simple_openapi_app): - app_client = simple_openapi_app.app.test_client() - - response = app_client.get( - '/v1.0/exploded-deep-object-param?id[foo]=bar') # type: flask.Response - assert response.status_code == 200 - response_data = json.loads(response.data.decode('utf-8', 'replace')) - assert response_data == {'foo': 'bar', 'foo4': 'blubb'} - - -def test_exploded_deep_object_param_endpoint_openapi_multiple_data_types(simple_openapi_app): - app_client = simple_openapi_app.app.test_client() - - response = app_client.get( - '/v1.0/exploded-deep-object-param?id[foo]=bar&id[fooint]=2&id[fooboo]=false') # type: flask.Response - assert response.status_code == 200 - response_data = json.loads(response.data.decode('utf-8', 'replace')) - assert response_data == {'foo': 'bar', - 'fooint': 2, 'fooboo': False, 'foo4': 'blubb'} - - -def test_exploded_deep_object_param_endpoint_openapi_additional_properties(simple_openapi_app): - app_client = simple_openapi_app.app.test_client() - - response = app_client.get( - '/v1.0/exploded-deep-object-param-additional-properties?id[foo]=bar&id[fooint]=2') # type: flask.Response - assert response.status_code == 200 - response_data = json.loads(response.data.decode('utf-8', 'replace')) - assert response_data == {'foo': 'bar', 'fooint': '2'} - - -def test_exploded_deep_object_param_endpoint_openapi_additional_properties_false(simple_openapi_app): - app_client = simple_openapi_app.app.test_client() - - response = app_client.get( - '/v1.0/exploded-deep-object-param?id[foo]=bar&id[foofoo]=barbar') # type: flask.Response - assert response.status_code == 400 - - -def test_exploded_deep_object_param_endpoint_openapi_with_dots(simple_openapi_app): - app_client = simple_openapi_app.app.test_client() - - response = app_client.get( - '/v1.0/exploded-deep-object-param-additional-properties?id[foo]=bar&id[foo.foo]=barbar') # type: flask.Response - assert response.status_code == 200 - response_data = json.loads(response.data.decode('utf-8', 'replace')) - assert response_data == {'foo': 'bar', 'foo.foo': 'barbar'} - - -def test_nested_exploded_deep_object_param_endpoint_openapi(simple_openapi_app): - app_client = simple_openapi_app.app.test_client() - - response = app_client.get( - '/v1.0/nested-exploded-deep-object-param?id[foo][foo2]=bar&id[foofoo]=barbar') # type: flask.Response - assert response.status_code == 200 - response_data = json.loads(response.data.decode('utf-8', 'replace')) - assert response_data == { - 'foo': {'foo2': 'bar', 'foo3': 'blubb'}, 'foofoo': 'barbar'} - - -def test_redirect_endpoint(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.get('/v1.0/test-redirect-endpoint') - assert resp.status_code == 302 - - -def test_redirect_response_endpoint(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.get('/v1.0/test-redirect-response-endpoint') - assert resp.status_code == 302 - - -def test_default_object_body(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.post('/v1.0/test-default-object-body') - assert resp.status_code == 200 - response = json.loads(resp.data.decode('utf-8', 'replace')) - assert response['stack'] == {'image_version': 'default_image'} - - resp = app_client.post('/v1.0/test-default-integer-body') - assert resp.status_code == 200 - response = json.loads(resp.data.decode('utf-8', 'replace')) - assert response == 1 - - -def test_empty_object_body(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.post( - '/v1.0/test-empty-object-body', - data=json.dumps({}), - headers={'Content-Type': 'application/json'}) - assert resp.status_code == 200 - response = json.loads(resp.data.decode('utf-8', 'replace')) - assert response['stack'] == {} - - -def test_nested_additional_properties(simple_openapi_app): - app_client = simple_openapi_app.app.test_client() - resp = app_client.post( - '/v1.0/test-nested-additional-properties', - data=json.dumps({"nested": {"object": True}}), - headers={'Content-Type': 'application/json'}) - assert resp.status_code == 200 - response = json.loads(resp.data.decode('utf-8', 'replace')) - assert response == {"nested": {"object": True}} - - -def test_custom_encoder(simple_app): - - class CustomEncoder(FlaskJSONEncoder): - def default(self, o): - if o.__class__.__name__ == 'DummyClass': - return "cool result" - return FlaskJSONEncoder.default(self, o) - - flask_app = simple_app.app - flask_app.json_encoder = CustomEncoder - app_client = flask_app.test_client() - - resp = app_client.get('/v1.0/custom-json-response') - assert resp.status_code == 200 - response = json.loads(resp.data.decode('utf-8', 'replace')) - assert response['theResult'] == 'cool result' - - -def test_content_type_not_json(simple_app): - app_client = simple_app.app.test_client() - - resp = app_client.get('/v1.0/blob-response') - assert resp.status_code == 200 - - # validate binary content - text, number = unpack('!4sh', resp.data) - assert text == b'cool' - assert number == 8 - - -def test_maybe_blob_or_json(simple_app): - app_client = simple_app.app.test_client() - - resp = app_client.get('/v1.0/binary-response') - assert resp.status_code == 200 - assert resp.content_type == 'application/octet-stream' - # validate binary content - text, number = unpack('!4sh', resp.data) - assert text == b'cool' - assert number == 8 - - -def test_bad_operations(bad_operations_app): - # Bad operationIds in bad_operations_app should result in 501 - app_client = bad_operations_app.app.test_client() - - resp = app_client.get('/v1.0/welcome') - assert resp.status_code == 501 - - resp = app_client.put('/v1.0/welcome') - assert resp.status_code == 501 - - resp = app_client.post('/v1.0/welcome') - assert resp.status_code == 501 - - -def test_text_request(simple_app): - app_client = simple_app.app.test_client() - - resp = app_client.post('/v1.0/text-request', data='text') - assert resp.status_code == 200 - - -def test_operation_handler_returns_flask_object(invalid_resp_allowed_app): - app_client = invalid_resp_allowed_app.app.test_client() - resp = app_client.get('/v1.0/get_non_conforming_response') - assert resp.status_code == 200 - - -def test_post_wrong_content_type(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.post('/v1.0/post_wrong_content_type', - content_type="application/xml", - data=json.dumps({"some": "data"}) - ) - assert resp.status_code == 415 - - resp = app_client.post('/v1.0/post_wrong_content_type', - data=json.dumps({"some": "data"}) - ) - assert resp.status_code == 415 - - resp = app_client.post('/v1.0/post_wrong_content_type', - content_type="application/x-www-form-urlencoded", - data="a=1&b=2" - ) - assert resp.status_code == 415 - - # this test checks exactly what the test directly above is supposed to check, - # i.e. no content-type is provided in the header - # unfortunately there is an issue with the werkzeug test environment - # (https://github.com/pallets/werkzeug/issues/1159) - # so that content-type is added to every request, we remove it here manually for our test - # this test can be removed once the werkzeug issue is addressed - builder = EnvironBuilder(path='/v1.0/post_wrong_content_type', method='POST', - data=json.dumps({"some": "data"})) - try: - environ = builder.get_environ() - finally: - builder.close() - - content_type = 'CONTENT_TYPE' - if content_type in environ: - environ.pop('CONTENT_TYPE') - # we cannot just call app_client.open() since app_client is a flask.testing.FlaskClient - # which overrides werkzeug.test.Client.open() but does not allow passing an environment - # directly - resp = Client.open(app_client, environ) - assert resp.status_code == 415 - - resp = app_client.post('/v1.0/post_wrong_content_type', - content_type="application/json", - data="not a valid json" - ) - assert resp.status_code == 400, \ - "Should return 400 when Content-Type is json but content not parsable" - - -def test_get_unicode_response(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.get('/v1.0/get_unicode_response') - actualJson = {'currency': '\xa3', 'key': 'leena'} - assert json.loads(resp.data.decode('utf-8', 'replace')) == actualJson - - -def test_get_enum_response(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.get('/v1.0/get_enum_response') - assert resp.status_code == 200 - - -def test_get_httpstatus_response(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.get('/v1.0/get_httpstatus_response') - assert resp.status_code == 200 - - -def test_get_bad_default_response(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.get('/v1.0/get_bad_default_response/200') - assert resp.status_code == 200 - - resp = app_client.get('/v1.0/get_bad_default_response/202') - assert resp.status_code == 500 - - -def test_streaming_response(simple_app): - app_client = simple_app.app.test_client() - resp = app_client.get('/v1.0/get_streaming_response') - assert resp.status_code == 200 +import json +from struct import unpack + +import yaml +from firetail.apps.flask_app import FlaskJSONEncoder +from werkzeug.test import Client, EnvironBuilder + + +def test_app(simple_app): + assert simple_app.port == 5001 + + app_client = simple_app.app.test_client() + + # by default the Swagger UI is enabled + swagger_ui = app_client.get('/v1.0/ui/') # type: flask.Response + assert swagger_ui.status_code == 200 + assert b"Swagger UI" in swagger_ui.data + + # test return Swagger UI static files + swagger_icon = app_client.get('/v1.0/ui/swagger-ui.js') # type: flask.Response + assert swagger_icon.status_code == 200 + + post_greeting_url = app_client.post('/v1.0/greeting/jsantos/the/third/of/his/name', data={}) # type: flask.Response + assert post_greeting_url.status_code == 200 + assert post_greeting_url.content_type == 'application/json' + greeting_response_url = json.loads(post_greeting_url.data.decode('utf-8')) + assert greeting_response_url['greeting'] == 'Hello jsantos thanks for the/third/of/his/name' + + post_greeting = app_client.post('/v1.0/greeting/jsantos', data={}) # type: flask.Response + assert post_greeting.status_code == 200 + assert post_greeting.content_type == 'application/json' + greeting_response = json.loads(post_greeting.data.decode('utf-8')) + assert greeting_response['greeting'] == 'Hello jsantos' + + get_bye = app_client.get('/v1.0/bye/jsantos') # type: flask.Response + assert get_bye.status_code == 200 + assert get_bye.data == b'Goodbye jsantos' + + post_greeting = app_client.post('/v1.0/greeting/jsantos', data={}) # type: flask.Response + assert post_greeting.status_code == 200 + assert post_greeting.content_type == 'application/json' + greeting_response = json.loads(post_greeting.data.decode('utf-8')) + assert greeting_response['greeting'] == 'Hello jsantos' + + +def test_openapi_yaml_behind_proxy(reverse_proxied_app): + """ Verify the swagger.json file is returned with base_path updated + according to X-Original-URI header. + """ + app_client = reverse_proxied_app.app.test_client() + + headers = {'X-Forwarded-Path': '/behind/proxy'} + + swagger_ui = app_client.get('/v1.0/ui/', headers=headers) + assert swagger_ui.status_code == 200 + + openapi_yaml = app_client.get( + '/v1.0/' + reverse_proxied_app._spec_file, + headers=headers + ) + assert openapi_yaml.status_code == 200 + assert openapi_yaml.headers.get('Content-Type') == 'text/yaml' + spec = yaml.load(openapi_yaml.data.decode('utf-8'), Loader=yaml.BaseLoader) + + if reverse_proxied_app._spec_file == 'swagger.yaml': + assert b'url = "/behind/proxy/v1.0/swagger.json"' in swagger_ui.data + assert spec.get('basePath') == '/behind/proxy/v1.0', \ + "basePath should contains original URI" + else: + assert b'url: "/behind/proxy/v1.0/openapi.json"' in swagger_ui.data + url = spec.get('servers', [{}])[0].get('url') + assert url == '/behind/proxy/v1.0', \ + "basePath should contains original URI" + + +def test_produce_decorator(simple_app): + app_client = simple_app.app.test_client() + + get_bye = app_client.get('/v1.0/bye/jsantos') # type: flask.Response + assert get_bye.content_type == 'text/plain; charset=utf-8' + + +def test_returning_flask_response_tuple(simple_app): + app_client = simple_app.app.test_client() + + result = app_client.get('/v1.0/flask_response_tuple') # type: flask.Response + assert result.status_code == 201 + assert result.content_type == 'application/json' + result_data = json.loads(result.data.decode('utf-8', 'replace')) + assert result_data == {'foo': 'bar'} + + +def test_jsonifier(simple_app): + app_client = simple_app.app.test_client() + + post_greeting = app_client.post('/v1.0/greeting/jsantos', data={}) # type: flask.Response + assert post_greeting.status_code == 200 + assert post_greeting.content_type == 'application/json' + greeting_reponse = json.loads(post_greeting.data.decode('utf-8', 'replace')) + assert greeting_reponse['greeting'] == 'Hello jsantos' + + get_list_greeting = app_client.get('/v1.0/list/jsantos', data={}) # type: flask.Response + assert get_list_greeting.status_code == 200 + assert get_list_greeting.content_type == 'application/json' + greeting_reponse = json.loads(get_list_greeting.data.decode('utf-8', 'replace')) + assert len(greeting_reponse) == 2 + assert greeting_reponse[0] == 'hello' + assert greeting_reponse[1] == 'jsantos' + + get_greetings = app_client.get('/v1.0/greetings/jsantos', data={}) # type: flask.Response + assert get_greetings.status_code == 200 + assert get_greetings.content_type == 'application/x.firetail+json' + greetings_reponse = json.loads(get_greetings.data.decode('utf-8', 'replace')) + assert len(greetings_reponse) == 1 + assert greetings_reponse['greetings'] == 'Hello jsantos' + + +def test_not_content_response(simple_app): + app_client = simple_app.app.test_client() + + get_no_content_response = app_client.get('/v1.0/test_no_content_response') + assert get_no_content_response.status_code == 204 + assert get_no_content_response.content_length is None + + +def test_pass_through(simple_app): + app_client = simple_app.app.test_client() + + response = app_client.get('/v1.0/multimime', data={}) # type: flask.Response + assert response.status_code == 200 + + +def test_empty(simple_app): + app_client = simple_app.app.test_client() + + response = app_client.get('/v1.0/empty') # type: flask.Response + assert response.status_code == 204 + assert not response.data + + +def test_exploded_deep_object_param_endpoint_openapi_simple(simple_openapi_app): + app_client = simple_openapi_app.app.test_client() + + response = app_client.get('/v1.0/exploded-deep-object-param?id[foo]=bar') # type: flask.Response + assert response.status_code == 200 + response_data = json.loads(response.data.decode('utf-8', 'replace')) + assert response_data == {'foo': 'bar', 'foo4': 'blubb'} + + +def test_exploded_deep_object_param_endpoint_openapi_multiple_data_types(simple_openapi_app): + app_client = simple_openapi_app.app.test_client() + + response = app_client.get('/v1.0/exploded-deep-object-param?id[foo]=bar&id[fooint]=2&id[fooboo]=false') # type: flask.Response + assert response.status_code == 200 + response_data = json.loads(response.data.decode('utf-8', 'replace')) + assert response_data == {'foo': 'bar', 'fooint': 2, 'fooboo': False, 'foo4': 'blubb'} + + +def test_exploded_deep_object_param_endpoint_openapi_additional_properties(simple_openapi_app): + app_client = simple_openapi_app.app.test_client() + + response = app_client.get('/v1.0/exploded-deep-object-param-additional-properties?id[foo]=bar&id[fooint]=2') # type: flask.Response + assert response.status_code == 200 + response_data = json.loads(response.data.decode('utf-8', 'replace')) + assert response_data == {'foo': 'bar', 'fooint': '2'} + + +def test_exploded_deep_object_param_endpoint_openapi_additional_properties_false(simple_openapi_app): + app_client = simple_openapi_app.app.test_client() + + response = app_client.get('/v1.0/exploded-deep-object-param?id[foo]=bar&id[foofoo]=barbar') # type: flask.Response + assert response.status_code == 400 + + +def test_exploded_deep_object_param_endpoint_openapi_with_dots(simple_openapi_app): + app_client = simple_openapi_app.app.test_client() + + response = app_client.get('/v1.0/exploded-deep-object-param-additional-properties?id[foo]=bar&id[foo.foo]=barbar') # type: flask.Response + assert response.status_code == 200 + response_data = json.loads(response.data.decode('utf-8', 'replace')) + assert response_data == {'foo': 'bar', 'foo.foo': 'barbar'} + + +def test_nested_exploded_deep_object_param_endpoint_openapi(simple_openapi_app): + app_client = simple_openapi_app.app.test_client() + + response = app_client.get('/v1.0/nested-exploded-deep-object-param?id[foo][foo2]=bar&id[foofoo]=barbar') # type: flask.Response + assert response.status_code == 200 + response_data = json.loads(response.data.decode('utf-8', 'replace')) + assert response_data == {'foo': {'foo2': 'bar', 'foo3': 'blubb'}, 'foofoo': 'barbar'} + + +def test_redirect_endpoint(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/test-redirect-endpoint') + assert resp.status_code == 302 + + +def test_redirect_response_endpoint(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/test-redirect-response-endpoint') + assert resp.status_code == 302 + + +def test_default_object_body(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.post('/v1.0/test-default-object-body') + assert resp.status_code == 200 + response = json.loads(resp.data.decode('utf-8', 'replace')) + assert response['stack'] == {'image_version': 'default_image'} + + resp = app_client.post('/v1.0/test-default-integer-body') + assert resp.status_code == 200 + response = json.loads(resp.data.decode('utf-8', 'replace')) + assert response == 1 + + +def test_empty_object_body(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.post( + '/v1.0/test-empty-object-body', + data=json.dumps({}), + headers={'Content-Type': 'application/json'}) + assert resp.status_code == 200 + response = json.loads(resp.data.decode('utf-8', 'replace')) + assert response['stack'] == {} + + +def test_nested_additional_properties(simple_openapi_app): + app_client = simple_openapi_app.app.test_client() + resp = app_client.post( + '/v1.0/test-nested-additional-properties', + data=json.dumps({"nested": {"object": True}}), + headers={'Content-Type': 'application/json'}) + assert resp.status_code == 200 + response = json.loads(resp.data.decode('utf-8', 'replace')) + assert response == {"nested": {"object": True}} + + +def test_custom_encoder(simple_app): + + class CustomEncoder(FlaskJSONEncoder): + def default(self, o): + if o.__class__.__name__ == 'DummyClass': + return "cool result" + return FlaskJSONEncoder.default(self, o) + + flask_app = simple_app.app + flask_app.json_encoder = CustomEncoder + app_client = flask_app.test_client() + + resp = app_client.get('/v1.0/custom-json-response') + assert resp.status_code == 200 + response = json.loads(resp.data.decode('utf-8', 'replace')) + assert response['theResult'] == 'cool result' + + +def test_content_type_not_json(simple_app): + app_client = simple_app.app.test_client() + + resp = app_client.get('/v1.0/blob-response') + assert resp.status_code == 200 + + # validate binary content + text, number = unpack('!4sh', resp.data) + assert text == b'cool' + assert number == 8 + + +def test_maybe_blob_or_json(simple_app): + app_client = simple_app.app.test_client() + + resp = app_client.get('/v1.0/binary-response') + assert resp.status_code == 200 + assert resp.content_type == 'application/octet-stream' + # validate binary content + text, number = unpack('!4sh', resp.data) + assert text == b'cool' + assert number == 8 + + +def test_bad_operations(bad_operations_app): + # Bad operationIds in bad_operations_app should result in 501 + app_client = bad_operations_app.app.test_client() + + resp = app_client.get('/v1.0/welcome') + assert resp.status_code == 501 + + resp = app_client.put('/v1.0/welcome') + assert resp.status_code == 501 + + resp = app_client.post('/v1.0/welcome') + assert resp.status_code == 501 + + +def test_text_request(simple_app): + app_client = simple_app.app.test_client() + + resp = app_client.post('/v1.0/text-request', data='text') + assert resp.status_code == 200 + + +def test_operation_handler_returns_flask_object(invalid_resp_allowed_app): + app_client = invalid_resp_allowed_app.app.test_client() + resp = app_client.get('/v1.0/get_non_conforming_response') + assert resp.status_code == 200 + + +def test_post_wrong_content_type(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.post('/v1.0/post_wrong_content_type', + content_type="application/xml", + data=json.dumps({"some": "data"}) + ) + assert resp.status_code == 415 + + resp = app_client.post('/v1.0/post_wrong_content_type', + data=json.dumps({"some": "data"}) + ) + assert resp.status_code == 415 + + resp = app_client.post('/v1.0/post_wrong_content_type', + content_type="application/x-www-form-urlencoded", + data="a=1&b=2" + ) + assert resp.status_code == 415 + + # this test checks exactly what the test directly above is supposed to check, + # i.e. no content-type is provided in the header + # unfortunately there is an issue with the werkzeug test environment + # (https://github.com/pallets/werkzeug/issues/1159) + # so that content-type is added to every request, we remove it here manually for our test + # this test can be removed once the werkzeug issue is addressed + builder = EnvironBuilder(path='/v1.0/post_wrong_content_type', method='POST', + data=json.dumps({"some": "data"})) + try: + environ = builder.get_environ() + finally: + builder.close() + + content_type = 'CONTENT_TYPE' + if content_type in environ: + environ.pop('CONTENT_TYPE') + # we cannot just call app_client.open() since app_client is a flask.testing.FlaskClient + # which overrides werkzeug.test.Client.open() but does not allow passing an environment + # directly + resp = Client.open(app_client, environ) + assert resp.status_code == 415 + + + resp = app_client.post('/v1.0/post_wrong_content_type', + content_type="application/json", + data="not a valid json" + ) + assert resp.status_code == 400, \ + "Should return 400 when Content-Type is json but content not parsable" + + +def test_get_unicode_response(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/get_unicode_response') + actualJson = {'currency': '\xa3', 'key': 'leena'} + assert json.loads(resp.data.decode('utf-8','replace')) == actualJson + + +def test_get_enum_response(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/get_enum_response') + assert resp.status_code == 200 + + +def test_get_httpstatus_response(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/get_httpstatus_response') + assert resp.status_code == 200 + + +def test_get_bad_default_response(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/get_bad_default_response/200') + assert resp.status_code == 200 + + resp = app_client.get('/v1.0/get_bad_default_response/202') + assert resp.status_code == 500 + + +def test_streaming_response(simple_app): + app_client = simple_app.app.test_client() + resp = app_client.get('/v1.0/get_streaming_response') + assert resp.status_code == 200 + + +def test_oneof(simple_openapi_app): + app_client = simple_openapi_app.app.test_client() + + post_greeting = app_client.post( # type: flask.Response + '/v1.0/oneof_greeting', + data=json.dumps({"name": 3}), + content_type="application/json" + ) + assert post_greeting.status_code == 200 + assert post_greeting.content_type == 'application/json' + greeting_reponse = json.loads(post_greeting.data.decode('utf-8', 'replace')) + assert greeting_reponse['greeting'] == 'Hello 3' + + post_greeting = app_client.post( # type: flask.Response + '/v1.0/oneof_greeting', + data=json.dumps({"name": True}), + content_type="application/json" + ) + assert post_greeting.status_code == 200 + assert post_greeting.content_type == 'application/json' + greeting_reponse = json.loads(post_greeting.data.decode('utf-8', 'replace')) + assert greeting_reponse['greeting'] == 'Hello True' + + post_greeting = app_client.post( # type: flask.Response + '/v1.0/oneof_greeting', + data=json.dumps({"name": "jsantos"}), + content_type="application/json" + ) + assert post_greeting.status_code == 400 diff --git a/tests/api/test_schema.py b/tests/api/test_schema.py index d1af2d0..243d110 100644 --- a/tests/api/test_schema.py +++ b/tests/api/test_schema.py @@ -1,239 +1,239 @@ -import json - - -def test_schema(schema_app): - app_client = schema_app.app.test_client() - headers = {'Content-type': 'application/json'} - - empty_request = app_client.post('/v1.0/test_schema', headers=headers, data=json.dumps({})) # type: flask.Response - assert empty_request.status_code == 400 - assert empty_request.content_type == 'application/problem+json' - empty_request_response = json.loads(empty_request.data.decode('utf-8', 'replace')) # type: dict - assert empty_request_response['title'] == 'Bad Request' - assert empty_request_response['detail'].startswith("'image_version' is a required property") - - bad_type = app_client.post('/v1.0/test_schema', headers=headers, - data=json.dumps({'image_version': 22})) # type: flask.Response - assert bad_type.status_code == 400 - assert bad_type.content_type == 'application/problem+json' - bad_type_response = json.loads(bad_type.data.decode('utf-8', 'replace')) # type: dict - assert bad_type_response['title'] == 'Bad Request' - assert bad_type_response['detail'].startswith("22 is not of type 'string'") - - bad_type_path = app_client.post('/v1.0/test_schema', headers=headers, - data=json.dumps({'image_version': 22})) # type: flask.Response - assert bad_type_path.status_code == 400 - assert bad_type_path.content_type == 'application/problem+json' - bad_type_path_response = json.loads(bad_type.data.decode('utf-8', 'replace')) # type: dict - assert bad_type_path_response['title'] == 'Bad Request' - assert bad_type_path_response['detail'].endswith(" - 'image_version'") - - good_request = app_client.post('/v1.0/test_schema', headers=headers, - data=json.dumps({'image_version': 'version'})) # type: flask.Response - assert good_request.status_code == 200 - good_request_response = json.loads(good_request.data.decode('utf-8', 'replace')) # type: dict - assert good_request_response['image_version'] == 'version' - - good_request_extra = app_client.post('/v1.0/test_schema', headers=headers, - data=json.dumps({'image_version': 'version', - 'extra': 'stuff'})) # type: flask.Response - assert good_request_extra.status_code == 200 - good_request_extra_response = json.loads(good_request.data.decode('utf-8', 'replace')) # type: dict - assert good_request_extra_response['image_version'] == 'version' - - wrong_type = app_client.post('/v1.0/test_schema', headers=headers, data=json.dumps(42)) # type: flask.Response - assert wrong_type.status_code == 400 - assert wrong_type.content_type == 'application/problem+json' - wrong_type_response = json.loads(wrong_type.data.decode('utf-8', 'replace')) # type: dict - assert wrong_type_response['title'] == 'Bad Request' - assert wrong_type_response['detail'].startswith("42 is not of type 'object'") - - -def test_schema_response(schema_app): - app_client = schema_app.app.test_client() - - request = app_client.get('/v1.0/test_schema/response/object/valid', headers={}, data=None) # type: flask.Response - assert request.status_code == 200 - request = app_client.get('/v1.0/test_schema/response/object/invalid_type', headers={}, - data=None) # type: flask.Response - assert request.status_code == 500 - request = app_client.get('/v1.0/test_schema/response/object/invalid_requirements', headers={}, - data=None) # type: flask.Response - assert request.status_code == 500 - request = app_client.get('/v1.0/test_schema/response/string/valid', headers={}, data=None) # type: flask.Response - assert request.status_code == 200 - request = app_client.get('/v1.0/test_schema/response/string/invalid', headers={}, data=None) # type: flask.Response - assert request.status_code == 500 - request = app_client.get('/v1.0/test_schema/response/integer/valid', headers={}, data=None) # type: flask.Response - assert request.status_code == 200 - request = app_client.get('/v1.0/test_schema/response/integer/invalid', headers={}, - data=None) # type: flask.Response - assert request.status_code == 500 - request = app_client.get('/v1.0/test_schema/response/number/valid', headers={}, data=None) # type: flask.Response - assert request.status_code == 200 - request = app_client.get('/v1.0/test_schema/response/number/invalid', headers={}, data=None) # type: flask.Response - assert request.status_code == 500 - request = app_client.get('/v1.0/test_schema/response/boolean/valid', headers={}, data=None) # type: flask.Response - assert request.status_code == 200 - request = app_client.get('/v1.0/test_schema/response/boolean/invalid', headers={}, - data=None) # type: flask.Response - assert request.status_code == 500 - request = app_client.get('/v1.0/test_schema/response/array/valid', headers={}, data=None) # type: flask.Response - assert request.status_code == 200 - request = app_client.get('/v1.0/test_schema/response/array/invalid_dict', headers={}, - data=None) # type: flask.Response - assert request.status_code == 500 - request = app_client.get('/v1.0/test_schema/response/array/invalid_string', headers={}, - data=None) # type: flask.Response - assert request.status_code == 500 - - -def test_schema_in_query(schema_app): - app_client = schema_app.app.test_client() - headers = {'Content-type': 'application/json'} - - good_request = app_client.post('/v1.0/test_schema_in_query', headers=headers, - query_string={'image_version': 'version', - 'not_required': 'test'}) # type: flask.Response - assert good_request.status_code == 200 - good_request_response = json.loads(good_request.data.decode('utf-8', 'replace')) # type: dict - assert good_request_response['image_version'] == 'version' - - -def test_schema_list(schema_app): - app_client = schema_app.app.test_client() - headers = {'Content-type': 'application/json'} - - wrong_type = app_client.post('/v1.0/test_schema_list', headers=headers, data=json.dumps(42)) # type: flask.Response - assert wrong_type.status_code == 400 - assert wrong_type.content_type == 'application/problem+json' - wrong_type_response = json.loads(wrong_type.data.decode('utf-8', 'replace')) # type: dict - assert wrong_type_response['title'] == 'Bad Request' - assert wrong_type_response['detail'].startswith("42 is not of type 'array'") - - wrong_items = app_client.post('/v1.0/test_schema_list', headers=headers, - data=json.dumps([42])) # type: flask.Response - assert wrong_items.status_code == 400 - assert wrong_items.content_type == 'application/problem+json' - wrong_items_response = json.loads(wrong_items.data.decode('utf-8', 'replace')) # type: dict - assert wrong_items_response['title'] == 'Bad Request' - assert wrong_items_response['detail'].startswith("42 is not of type 'string'") - - -def test_schema_map(schema_app): - app_client = schema_app.app.test_client() - headers = {'Content-type': 'application/json'} - - valid_object = { - "foo": { - "image_version": "string" - }, - "bar": { - "image_version": "string" - } - } - - invalid_object = { - "foo": 42 - } - - wrong_type = app_client.post('/v1.0/test_schema_map', headers=headers, data=json.dumps(42)) # type: flask.Response - assert wrong_type.status_code == 400 - assert wrong_type.content_type == 'application/problem+json' - wrong_type_response = json.loads(wrong_type.data.decode('utf-8', 'replace')) # type: dict - assert wrong_type_response['title'] == 'Bad Request' - assert wrong_type_response['detail'].startswith("42 is not of type 'object'") - - wrong_items = app_client.post('/v1.0/test_schema_map', headers=headers, - data=json.dumps(invalid_object)) # type: flask.Response - assert wrong_items.status_code == 400 - assert wrong_items.content_type == 'application/problem+json' - wrong_items_response = json.loads(wrong_items.data.decode('utf-8', 'replace')) # type: dict - assert wrong_items_response['title'] == 'Bad Request' - assert wrong_items_response['detail'].startswith("42 is not of type 'object'") - - right_type = app_client.post('/v1.0/test_schema_map', headers=headers, - data=json.dumps(valid_object)) # type: flask.Response - assert right_type.status_code == 200 - - -def test_schema_recursive(schema_app): - app_client = schema_app.app.test_client() - headers = {'Content-type': 'application/json'} - - valid_object = { - "children": [ - {"children": []}, - {"children": [ - {"children": []}, - ]}, - {"children": []}, - ] - } - - invalid_object = { - "children": [42] - } - - wrong_type = app_client.post('/v1.0/test_schema_recursive', headers=headers, - data=json.dumps(42)) # type: flask.Response - assert wrong_type.status_code == 400 - assert wrong_type.content_type == 'application/problem+json' - wrong_type_response = json.loads(wrong_type.data.decode('utf-8')) # type: dict - assert wrong_type_response['title'] == 'Bad Request' - assert wrong_type_response['detail'].startswith("42 is not of type 'object'") - - wrong_items = app_client.post('/v1.0/test_schema_recursive', headers=headers, - data=json.dumps(invalid_object)) # type: flask.Response - assert wrong_items.status_code == 400 - assert wrong_items.content_type == 'application/problem+json' - wrong_items_response = json.loads(wrong_items.data.decode('utf-8')) # type: dict - assert wrong_items_response['title'] == 'Bad Request' - assert wrong_items_response['detail'].startswith("42 is not of type 'object'") - - right_type = app_client.post('/v1.0/test_schema_recursive', headers=headers, - data=json.dumps(valid_object)) # type: flask.Response - assert right_type.status_code == 200 - - -def test_schema_format(schema_app): - app_client = schema_app.app.test_client() - headers = {'Content-type': 'application/json'} - - wrong_type = app_client.post('/v1.0/test_schema_format', headers=headers, - data=json.dumps("xy")) # type: flask.Response - assert wrong_type.status_code == 400 - assert wrong_type.content_type == 'application/problem+json' - wrong_type_response = json.loads(wrong_type.data.decode('utf-8', 'replace')) # type: dict - assert wrong_type_response['title'] == 'Bad Request' - assert "'xy' is not a 'email'" in wrong_type_response['detail'] - - -def test_schema_array(schema_app): - app_client = schema_app.app.test_client() - headers = {'Content-type': 'application/json'} - - array_request = app_client.post('/v1.0/schema_array', headers=headers, - data=json.dumps(['list', 'hello'])) # type: flask.Response - assert array_request.status_code == 200 - assert array_request.content_type == 'application/json' - array_response = json.loads(array_request.data.decode('utf-8', 'replace')) # type: list - assert array_response == ['list', 'hello'] - - -def test_schema_int(schema_app): - app_client = schema_app.app.test_client() - headers = {'Content-type': 'application/json'} - - array_request = app_client.post('/v1.0/schema_int', headers=headers, - data=json.dumps(42)) # type: flask.Response - assert array_request.status_code == 200 - assert array_request.content_type == 'application/json' - array_response = json.loads(array_request.data.decode('utf-8', 'replace')) # type: list - assert array_response == 42 - - -def test_global_response_definitions(schema_app): - app_client = schema_app.app.test_client() - resp = app_client.get('/v1.0/define_global_response') - assert json.loads(resp.data.decode('utf-8', 'replace')) == ['general', 'list'] +import json + + +def test_schema(schema_app): + app_client = schema_app.app.test_client() + headers = {'Content-type': 'application/json'} + + empty_request = app_client.post('/v1.0/test_schema', headers=headers, data=json.dumps({})) # type: flask.Response + assert empty_request.status_code == 400 + assert empty_request.content_type == 'application/problem+json' + empty_request_response = json.loads(empty_request.data.decode('utf-8', 'replace')) # type: dict + assert empty_request_response['title'] == 'Bad Request' + assert empty_request_response['detail'].startswith("'image_version' is a required property") + + bad_type = app_client.post('/v1.0/test_schema', headers=headers, + data=json.dumps({'image_version': 22})) # type: flask.Response + assert bad_type.status_code == 400 + assert bad_type.content_type == 'application/problem+json' + bad_type_response = json.loads(bad_type.data.decode('utf-8', 'replace')) # type: dict + assert bad_type_response['title'] == 'Bad Request' + assert bad_type_response['detail'].startswith("22 is not of type 'string'") + + bad_type_path = app_client.post('/v1.0/test_schema', headers=headers, + data=json.dumps({'image_version': 22})) # type: flask.Response + assert bad_type_path.status_code == 400 + assert bad_type_path.content_type == 'application/problem+json' + bad_type_path_response = json.loads(bad_type.data.decode('utf-8', 'replace')) # type: dict + assert bad_type_path_response['title'] == 'Bad Request' + assert bad_type_path_response['detail'].endswith(" - 'image_version'") + + good_request = app_client.post('/v1.0/test_schema', headers=headers, + data=json.dumps({'image_version': 'version'})) # type: flask.Response + assert good_request.status_code == 200 + good_request_response = json.loads(good_request.data.decode('utf-8', 'replace')) # type: dict + assert good_request_response['image_version'] == 'version' + + good_request_extra = app_client.post('/v1.0/test_schema', headers=headers, + data=json.dumps({'image_version': 'version', + 'extra': 'stuff'})) # type: flask.Response + assert good_request_extra.status_code == 200 + good_request_extra_response = json.loads(good_request.data.decode('utf-8', 'replace')) # type: dict + assert good_request_extra_response['image_version'] == 'version' + + wrong_type = app_client.post('/v1.0/test_schema', headers=headers, data=json.dumps(42)) # type: flask.Response + assert wrong_type.status_code == 400 + assert wrong_type.content_type == 'application/problem+json' + wrong_type_response = json.loads(wrong_type.data.decode('utf-8', 'replace')) # type: dict + assert wrong_type_response['title'] == 'Bad Request' + assert wrong_type_response['detail'].startswith("42 is not of type 'object'") + + +def test_schema_response(schema_app): + app_client = schema_app.app.test_client() + + request = app_client.get('/v1.0/test_schema/response/object/valid', headers={}, data=None) # type: flask.Response + assert request.status_code == 200 + request = app_client.get('/v1.0/test_schema/response/object/invalid_type', headers={}, + data=None) # type: flask.Response + assert request.status_code == 500 + request = app_client.get('/v1.0/test_schema/response/object/invalid_requirements', headers={}, + data=None) # type: flask.Response + assert request.status_code == 500 + request = app_client.get('/v1.0/test_schema/response/string/valid', headers={}, data=None) # type: flask.Response + assert request.status_code == 200 + request = app_client.get('/v1.0/test_schema/response/string/invalid', headers={}, data=None) # type: flask.Response + assert request.status_code == 500 + request = app_client.get('/v1.0/test_schema/response/integer/valid', headers={}, data=None) # type: flask.Response + assert request.status_code == 200 + request = app_client.get('/v1.0/test_schema/response/integer/invalid', headers={}, + data=None) # type: flask.Response + assert request.status_code == 500 + request = app_client.get('/v1.0/test_schema/response/number/valid', headers={}, data=None) # type: flask.Response + assert request.status_code == 200 + request = app_client.get('/v1.0/test_schema/response/number/invalid', headers={}, data=None) # type: flask.Response + assert request.status_code == 500 + request = app_client.get('/v1.0/test_schema/response/boolean/valid', headers={}, data=None) # type: flask.Response + assert request.status_code == 200 + request = app_client.get('/v1.0/test_schema/response/boolean/invalid', headers={}, + data=None) # type: flask.Response + assert request.status_code == 500 + request = app_client.get('/v1.0/test_schema/response/array/valid', headers={}, data=None) # type: flask.Response + assert request.status_code == 200 + request = app_client.get('/v1.0/test_schema/response/array/invalid_dict', headers={}, + data=None) # type: flask.Response + assert request.status_code == 500 + request = app_client.get('/v1.0/test_schema/response/array/invalid_string', headers={}, + data=None) # type: flask.Response + assert request.status_code == 500 + + +def test_schema_in_query(schema_app): + app_client = schema_app.app.test_client() + headers = {'Content-type': 'application/json'} + + good_request = app_client.post('/v1.0/test_schema_in_query', headers=headers, + query_string={'image_version': 'version', + 'not_required': 'test'}) # type: flask.Response + assert good_request.status_code == 200 + good_request_response = json.loads(good_request.data.decode('utf-8', 'replace')) # type: dict + assert good_request_response['image_version'] == 'version' + + +def test_schema_list(schema_app): + app_client = schema_app.app.test_client() + headers = {'Content-type': 'application/json'} + + wrong_type = app_client.post('/v1.0/test_schema_list', headers=headers, data=json.dumps(42)) # type: flask.Response + assert wrong_type.status_code == 400 + assert wrong_type.content_type == 'application/problem+json' + wrong_type_response = json.loads(wrong_type.data.decode('utf-8', 'replace')) # type: dict + assert wrong_type_response['title'] == 'Bad Request' + assert wrong_type_response['detail'].startswith("42 is not of type 'array'") + + wrong_items = app_client.post('/v1.0/test_schema_list', headers=headers, + data=json.dumps([42])) # type: flask.Response + assert wrong_items.status_code == 400 + assert wrong_items.content_type == 'application/problem+json' + wrong_items_response = json.loads(wrong_items.data.decode('utf-8', 'replace')) # type: dict + assert wrong_items_response['title'] == 'Bad Request' + assert wrong_items_response['detail'].startswith("42 is not of type 'string'") + + +def test_schema_map(schema_app): + app_client = schema_app.app.test_client() + headers = {'Content-type': 'application/json'} + + valid_object = { + "foo": { + "image_version": "string" + }, + "bar": { + "image_version": "string" + } + } + + invalid_object = { + "foo": 42 + } + + wrong_type = app_client.post('/v1.0/test_schema_map', headers=headers, data=json.dumps(42)) # type: flask.Response + assert wrong_type.status_code == 400 + assert wrong_type.content_type == 'application/problem+json' + wrong_type_response = json.loads(wrong_type.data.decode('utf-8', 'replace')) # type: dict + assert wrong_type_response['title'] == 'Bad Request' + assert wrong_type_response['detail'].startswith("42 is not of type 'object'") + + wrong_items = app_client.post('/v1.0/test_schema_map', headers=headers, + data=json.dumps(invalid_object)) # type: flask.Response + assert wrong_items.status_code == 400 + assert wrong_items.content_type == 'application/problem+json' + wrong_items_response = json.loads(wrong_items.data.decode('utf-8', 'replace')) # type: dict + assert wrong_items_response['title'] == 'Bad Request' + assert wrong_items_response['detail'].startswith("42 is not of type 'object'") + + right_type = app_client.post('/v1.0/test_schema_map', headers=headers, + data=json.dumps(valid_object)) # type: flask.Response + assert right_type.status_code == 200 + + +def test_schema_recursive(schema_app): + app_client = schema_app.app.test_client() + headers = {'Content-type': 'application/json'} + + valid_object = { + "children": [ + {"children": []}, + {"children": [ + {"children": []}, + ]}, + {"children": []}, + ] + } + + invalid_object = { + "children": [42] + } + + wrong_type = app_client.post('/v1.0/test_schema_recursive', headers=headers, + data=json.dumps(42)) # type: flask.Response + assert wrong_type.status_code == 400 + assert wrong_type.content_type == 'application/problem+json' + wrong_type_response = json.loads(wrong_type.data.decode('utf-8')) # type: dict + assert wrong_type_response['title'] == 'Bad Request' + assert wrong_type_response['detail'].startswith("42 is not of type 'object'") + + wrong_items = app_client.post('/v1.0/test_schema_recursive', headers=headers, + data=json.dumps(invalid_object)) # type: flask.Response + assert wrong_items.status_code == 400 + assert wrong_items.content_type == 'application/problem+json' + wrong_items_response = json.loads(wrong_items.data.decode('utf-8')) # type: dict + assert wrong_items_response['title'] == 'Bad Request' + assert wrong_items_response['detail'].startswith("42 is not of type 'object'") + + right_type = app_client.post('/v1.0/test_schema_recursive', headers=headers, + data=json.dumps(valid_object)) # type: flask.Response + assert right_type.status_code == 200 + + +def test_schema_format(schema_app): + app_client = schema_app.app.test_client() + headers = {'Content-type': 'application/json'} + + wrong_type = app_client.post('/v1.0/test_schema_format', headers=headers, + data=json.dumps("xy")) # type: flask.Response + assert wrong_type.status_code == 400 + assert wrong_type.content_type == 'application/problem+json' + wrong_type_response = json.loads(wrong_type.data.decode('utf-8', 'replace')) # type: dict + assert wrong_type_response['title'] == 'Bad Request' + assert "'xy' is not a 'email'" in wrong_type_response['detail'] + + +def test_schema_array(schema_app): + app_client = schema_app.app.test_client() + headers = {'Content-type': 'application/json'} + + array_request = app_client.post('/v1.0/schema_array', headers=headers, + data=json.dumps(['list', 'hello'])) # type: flask.Response + assert array_request.status_code == 200 + assert array_request.content_type == 'application/json' + array_response = json.loads(array_request.data.decode('utf-8', 'replace')) # type: list + assert array_response == ['list', 'hello'] + + +def test_schema_int(schema_app): + app_client = schema_app.app.test_client() + headers = {'Content-type': 'application/json'} + + array_request = app_client.post('/v1.0/schema_int', headers=headers, + data=json.dumps(42)) # type: flask.Response + assert array_request.status_code == 200 + assert array_request.content_type == 'application/json' + array_response = json.loads(array_request.data.decode('utf-8', 'replace')) # type: list + assert array_response == 42 + + +def test_global_response_definitions(schema_app): + app_client = schema_app.app.test_client() + resp = app_client.get('/v1.0/define_global_response') + assert json.loads(resp.data.decode('utf-8', 'replace')) == ['general', 'list'] diff --git a/tests/api/test_secure_api.py b/tests/api/test_secure_api.py index 9eb422e..2ceac77 100644 --- a/tests/api/test_secure_api.py +++ b/tests/api/test_secure_api.py @@ -1,152 +1,134 @@ -import json - - -def test_security_over_nonexistent_endpoints(oauth_requests, secure_api_app): - app_client = secure_api_app.app.test_client() - headers = {"Authorization": "Bearer 300"} - get_inexistent_endpoint = app_client.get('/v1.0/does-not-exist-invalid-token', - headers=headers) # type: flask.Response - assert get_inexistent_endpoint.status_code == 401 - assert get_inexistent_endpoint.content_type == 'application/problem+json' - - headers = {"Authorization": "Bearer 100"} - get_inexistent_endpoint = app_client.get('/v1.0/does-not-exist-valid-token', - headers=headers) # type: flask.Response - assert get_inexistent_endpoint.status_code == 404 - assert get_inexistent_endpoint.content_type == 'application/problem+json' - - get_inexistent_endpoint = app_client.get( - '/v1.0/does-not-exist-no-token') # type: flask.Response - assert get_inexistent_endpoint.status_code == 401 - - swagger_ui = app_client.get('/v1.0/ui/') # type: flask.Response - assert swagger_ui.status_code == 401 - - headers = {"Authorization": "Bearer 100"} - post_greeting = app_client.post( - '/v1.0/greeting/rcaricio', data={}, headers=headers) # type: flask.Response - assert post_greeting.status_code == 200 - - post_greeting = app_client.post( - '/v1.0/greeting/rcaricio', data={}) # type: flask.Response - assert post_greeting.status_code == 401 - - -def test_security(oauth_requests, secure_endpoint_app): - app_client = secure_endpoint_app.app.test_client() - - get_bye_no_auth = app_client.get( - '/v1.0/byesecure/jsantos') # type: flask.Response - assert get_bye_no_auth.status_code == 401 - assert get_bye_no_auth.content_type == 'application/problem+json' - get_bye_no_auth_reponse = json.loads( - get_bye_no_auth.data.decode('utf-8', 'replace')) # type: dict - assert get_bye_no_auth_reponse['title'] == 'Unauthorized' - assert get_bye_no_auth_reponse['detail'] == "No authorization token provided" - - headers = {"Authorization": "Bearer 100"} - get_bye_good_auth = app_client.get( - '/v1.0/byesecure/jsantos', headers=headers) # type: flask.Response - assert get_bye_good_auth.status_code == 200 - assert get_bye_good_auth.data == b'Goodbye jsantos (Secure: test-user)' - - headers = {"Authorization": "Bearer 200"} - get_bye_wrong_scope = app_client.get( - '/v1.0/byesecure/jsantos', headers=headers) # type: flask.Response - assert get_bye_wrong_scope.status_code == 403 - assert get_bye_wrong_scope.content_type == 'application/problem+json' - get_bye_wrong_scope_reponse = json.loads( - get_bye_wrong_scope.data.decode('utf-8', 'replace')) # type: dict - assert get_bye_wrong_scope_reponse['title'] == 'Forbidden' - assert get_bye_wrong_scope_reponse['detail'] == "Provided token doesn't have the required scope" - - headers = {"Authorization": "Bearer 300"} - get_bye_bad_token = app_client.get( - '/v1.0/byesecure/jsantos', headers=headers) # type: flask.Response - assert get_bye_bad_token.status_code == 401 - assert get_bye_bad_token.content_type == 'application/problem+json' - get_bye_bad_token_reponse = json.loads( - get_bye_bad_token.data.decode('utf-8', 'replace')) # type: dict - assert get_bye_bad_token_reponse['title'] == 'Unauthorized' - assert get_bye_bad_token_reponse['detail'] == "Provided token is not valid" - - response = app_client.get( - '/v1.0/more-than-one-security-definition') # type: flask.Response - assert response.status_code == 401 - - # also tests case-insensitivity - headers = {"X-AUTH": "mykey"} - response = app_client.get( - '/v1.0/more-than-one-security-definition', headers=headers) # type: flask.Response - assert response.status_code == 200 - - headers = {"Authorization": "Bearer 100"} - get_bye_good_auth = app_client.get('/v1.0/byesecure-ignoring-context/hjacobs', - headers=headers) # type: flask.Response - assert get_bye_good_auth.status_code == 200 - assert get_bye_good_auth.data == b'Goodbye hjacobs (Secure!)' - - headers = {"Authorization": "Bearer 100"} - get_bye_from_flask = app_client.get( - '/v1.0/byesecure-from-flask', headers=headers) # type: flask.Response - assert get_bye_from_flask.data == b'Goodbye test-user (Secure!)' - - headers = {"Authorization": "Bearer 100"} - get_bye_from_firetail = app_client.get( - '/v1.0/byesecure-from-firetail', headers=headers) # type: flask.Response - assert get_bye_from_firetail.data == b'Goodbye test-user (Secure!)' - - headers = {"Authorization": "Bearer 100"} - get_bye_from_firetail = app_client.get( - '/v1.0/byesecure-jwt/test-user', headers=headers) # type: flask.Response - assert get_bye_from_firetail.data == b'Goodbye test-user (Secure: 100)' - - # has optional auth - response = app_client.get('/v1.0/optional-auth') # type: flask.Response - assert response.status_code == 200 - assert response.data == b'"Unauthenticated"\n' - headers = {"X-AUTH": "mykey"} - response = app_client.get('/v1.0/optional-auth', - headers=headers) # type: flask.Response - assert response.status_code == 200 - assert response.data == b'"Authenticated"\n' - headers = {"X-AUTH": "wrong-key"} - response = app_client.get('/v1.0/optional-auth', - headers=headers) # type: flask.Response - assert response.data == b'"Unauthenticated"\n' - assert response.status_code == 200 - - -def test_checking_that_client_token_has_all_necessary_scopes( - oauth_requests, secure_endpoint_app): - app_client = secure_endpoint_app.app.test_client() - - # has only one of the required scopes - headers = {"Authorization": "Bearer has_myscope"} - response = app_client.get( - '/v1.0/more-than-one-scope', headers=headers) # type: flask.Response - assert response.status_code == 403 - - # has none of the necessary scopes - headers = {"Authorization": "Bearer has_wrongscope"} - response = app_client.get( - '/v1.0/more-than-one-scope', headers=headers) # type: flask.Response - assert response.status_code == 403 - - # is not auth - headers = {"Authorization": "Bearer is_not_invalid"} - response = app_client.get( - '/v1.0/more-than-one-scope', headers=headers) # type: flask.Response - assert response.status_code == 401 - - # has all necessary scopes - headers = {"Authorization": "Bearer has_myscope_otherscope"} - response = app_client.get( - '/v1.0/more-than-one-scope', headers=headers) # type: flask.Response - assert response.status_code == 200 - - # has all necessary scopes but under key 'scopes' - headers = {"Authorization": "Bearer has_scopes_in_scopes_with_s"} - response = app_client.get( - '/v1.0/more-than-one-scope', headers=headers) # type: flask.Response - assert response.status_code == 200 +import json + + +def test_security_over_nonexistent_endpoints(oauth_requests, secure_api_app): + app_client = secure_api_app.app.test_client() + headers = {"Authorization": "Bearer 300"} + get_inexistent_endpoint = app_client.get('/v1.0/does-not-exist-invalid-token', + headers=headers) # type: flask.Response + assert get_inexistent_endpoint.status_code == 401 + assert get_inexistent_endpoint.content_type == 'application/problem+json' + + headers = {"Authorization": "Bearer 100"} + get_inexistent_endpoint = app_client.get('/v1.0/does-not-exist-valid-token', + headers=headers) # type: flask.Response + assert get_inexistent_endpoint.status_code == 404 + assert get_inexistent_endpoint.content_type == 'application/problem+json' + + get_inexistent_endpoint = app_client.get('/v1.0/does-not-exist-no-token') # type: flask.Response + assert get_inexistent_endpoint.status_code == 401 + + swagger_ui = app_client.get('/v1.0/ui/') # type: flask.Response + assert swagger_ui.status_code == 401 + + headers = {"Authorization": "Bearer 100"} + post_greeting = app_client.post('/v1.0/greeting/rcaricio', data={}, headers=headers) # type: flask.Response + assert post_greeting.status_code == 200 + + post_greeting = app_client.post('/v1.0/greeting/rcaricio', data={}) # type: flask.Response + assert post_greeting.status_code == 401 + + +def test_security(oauth_requests, secure_endpoint_app): + app_client = secure_endpoint_app.app.test_client() + + get_bye_no_auth = app_client.get('/v1.0/byesecure/jsantos') # type: flask.Response + assert get_bye_no_auth.status_code == 401 + assert get_bye_no_auth.content_type == 'application/problem+json' + get_bye_no_auth_reponse = json.loads(get_bye_no_auth.data.decode('utf-8', 'replace')) # type: dict + assert get_bye_no_auth_reponse['title'] == 'Unauthorized' + assert get_bye_no_auth_reponse['detail'] == "No authorization token provided" + + headers = {"Authorization": "Bearer 100"} + get_bye_good_auth = app_client.get('/v1.0/byesecure/jsantos', headers=headers) # type: flask.Response + assert get_bye_good_auth.status_code == 200 + assert get_bye_good_auth.data == b'Goodbye jsantos (Secure: test-user)' + + headers = {"Authorization": "Bearer 200"} + get_bye_wrong_scope = app_client.get('/v1.0/byesecure/jsantos', headers=headers) # type: flask.Response + assert get_bye_wrong_scope.status_code == 403 + assert get_bye_wrong_scope.content_type == 'application/problem+json' + get_bye_wrong_scope_reponse = json.loads(get_bye_wrong_scope.data.decode('utf-8', 'replace')) # type: dict + assert get_bye_wrong_scope_reponse['title'] == 'Forbidden' + assert get_bye_wrong_scope_reponse['detail'] == "Provided token doesn't have the required scope" + + headers = {"Authorization": "Bearer 300"} + get_bye_bad_token = app_client.get('/v1.0/byesecure/jsantos', headers=headers) # type: flask.Response + assert get_bye_bad_token.status_code == 401 + assert get_bye_bad_token.content_type == 'application/problem+json' + get_bye_bad_token_reponse = json.loads(get_bye_bad_token.data.decode('utf-8', 'replace')) # type: dict + assert get_bye_bad_token_reponse['title'] == 'Unauthorized' + assert get_bye_bad_token_reponse['detail'] == "Provided token is not valid" + + response = app_client.get('/v1.0/more-than-one-security-definition') # type: flask.Response + assert response.status_code == 401 + + # also tests case-insensitivity + headers = {"X-AUTH": "mykey"} + response = app_client.get('/v1.0/more-than-one-security-definition', headers=headers) # type: flask.Response + assert response.status_code == 200 + + headers = {"Authorization": "Bearer 100"} + get_bye_good_auth = app_client.get('/v1.0/byesecure-ignoring-context/hjacobs', + headers=headers) # type: flask.Response + assert get_bye_good_auth.status_code == 200 + assert get_bye_good_auth.data == b'Goodbye hjacobs (Secure!)' + + headers = {"Authorization": "Bearer 100"} + get_bye_from_flask = app_client.get('/v1.0/byesecure-from-flask', headers=headers) # type: flask.Response + assert get_bye_from_flask.data == b'Goodbye test-user (Secure!)' + + headers = {"Authorization": "Bearer 100"} + get_bye_from_firetail = app_client.get('/v1.0/byesecure-from-firetail', headers=headers) # type: flask.Response + assert get_bye_from_firetail.data == b'Goodbye test-user (Secure!)' + + headers = {"Authorization": "Bearer 100"} + get_bye_from_firetail = app_client.get('/v1.0/byesecure-jwt/test-user', headers=headers) # type: flask.Response + assert get_bye_from_firetail.data == b'Goodbye test-user (Secure: 100)' + + # has optional auth + response = app_client.get('/v1.0/optional-auth') # type: flask.Response + assert response.status_code == 200 + assert response.data == b'"Unauthenticated"\n' + headers = {"X-AUTH": "mykey"} + response = app_client.get('/v1.0/optional-auth', headers=headers) # type: flask.Response + assert response.status_code == 200 + assert response.data == b'"Authenticated"\n' + headers = {"X-AUTH": "wrong-key"} + response = app_client.get('/v1.0/optional-auth', headers=headers) # type: flask.Response + assert response.data == b'"Unauthenticated"\n' + assert response.status_code == 200 + + # security function throws exception + response = app_client.get('/v1.0/auth-exception', headers={'X-Api-Key': 'foo'}) + assert response.status_code == 401 + + +def test_checking_that_client_token_has_all_necessary_scopes( + oauth_requests, secure_endpoint_app): + app_client = secure_endpoint_app.app.test_client() + + # has only one of the required scopes + headers = {"Authorization": "Bearer has_myscope"} + response = app_client.get('/v1.0/more-than-one-scope', headers=headers) # type: flask.Response + assert response.status_code == 403 + + # has none of the necessary scopes + headers = {"Authorization": "Bearer has_wrongscope"} + response = app_client.get('/v1.0/more-than-one-scope', headers=headers) # type: flask.Response + assert response.status_code == 403 + + # is not auth + headers = {"Authorization": "Bearer is_not_invalid"} + response = app_client.get('/v1.0/more-than-one-scope', headers=headers) # type: flask.Response + assert response.status_code == 401 + + # has all necessary scopes + headers = {"Authorization": "Bearer has_myscope_otherscope"} + response = app_client.get('/v1.0/more-than-one-scope', headers=headers) # type: flask.Response + assert response.status_code == 200 + + # has all necessary scopes but under key 'scopes' + headers = {"Authorization": "Bearer has_scopes_in_scopes_with_s"} + response = app_client.get('/v1.0/more-than-one-scope', headers=headers) # type: flask.Response + assert response.status_code == 200 diff --git a/tests/api/test_unordered_definition.py b/tests/api/test_unordered_definition.py index 4a53ece..fbe93ea 100644 --- a/tests/api/test_unordered_definition.py +++ b/tests/api/test_unordered_definition.py @@ -1,9 +1,9 @@ -import json - - -def test_app(unordered_definition_app): - app_client = unordered_definition_app.app.test_client() - response = app_client.get('/v1.0/unordered-params/1?first=first&second=2') # type: flask.Response - assert response.status_code == 400 - response_data = json.loads(response.data.decode('utf-8', 'replace')) - assert response_data['detail'] == 'Wrong type, expected \'integer\' for query parameter \'first\'' +import json + + +def test_app(unordered_definition_app): + app_client = unordered_definition_app.app.test_client() + response = app_client.get('/v1.0/unordered-params/1?first=first&second=2') # type: flask.Response + assert response.status_code == 400 + response_data = json.loads(response.data.decode('utf-8', 'replace')) + assert response_data['detail'] == 'Wrong type, expected \'integer\' for query parameter \'first\'' diff --git a/tests/conftest.py b/tests/conftest.py index b577336..096f000 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,260 +1,234 @@ -import json -import logging -import pathlib -import sys - -import pytest -from firetail import App -from firetail.security import FlaskSecurityHandlerFactory -from werkzeug.test import Client, EnvironBuilder - -logging.basicConfig(level=logging.DEBUG) - -TEST_FOLDER = pathlib.Path(__file__).parent -FIXTURES_FOLDER = TEST_FOLDER / 'fixtures' -SPEC_FOLDER = TEST_FOLDER / "fakeapi" -OPENAPI2_SPEC = ["swagger.yaml"] -OPENAPI3_SPEC = ["openapi.yaml"] -SPECS = OPENAPI2_SPEC + OPENAPI3_SPEC - - -class FakeResponse: - def __init__(self, status_code, text): - """ - :type status_code: int - :type text: ste - """ - self.status_code = status_code - self.text = text - self.ok = status_code == 200 - - def json(self): - return json.loads(self.text) - - -def fixed_get_environ(): - """See https://github.com/pallets/werkzeug/issues/2347""" - - original_get_environ = EnvironBuilder.get_environ - - def f(self): - result = original_get_environ(self) - result.pop("HTTP_CONTENT_TYPE", None) - result.pop("HTTP_CONTENT_LENGTH", None) - return result - - return f - - -EnvironBuilder.get_environ = fixed_get_environ() - - -def buffered_open(): - """For use with ASGI middleware""" - - original_open = Client.open - - def f(*args, **kwargs): - kwargs["buffered"] = True - return original_open(*args, **kwargs) - - return f - - -Client.open = buffered_open() - - -# Helper fixtures functions -# ========================= - - -@pytest.fixture -def oauth_requests(monkeypatch): - def fake_get(url, params=None, headers=None, timeout=None): - """ - :type url: str - :type params: dict| None - """ - headers = headers or {} - if url == "https://oauth.example/token_info": - token = headers.get('Authorization', 'invalid').split()[-1] - if token in ["100", "has_myscope"]: - return FakeResponse(200, '{"uid": "test-user", "scope": ["myscope"]}') - if token in ["200", "has_wrongscope"]: - return FakeResponse(200, '{"uid": "test-user", "scope": ["wrongscope"]}') - if token == "has_myscope_otherscope": - return FakeResponse(200, '{"uid": "test-user", "scope": ["myscope", "otherscope"]}') - if token in ["300", "is_not_invalid"]: - return FakeResponse(404, '') - if token == "has_scopes_in_scopes_with_s": - return FakeResponse(200, '{"uid": "test-user", "scopes": ["myscope", "otherscope"]}') - return url - - monkeypatch.setattr( - 'firetail.security.flask_security_handler_factory.session.get', fake_get) - - -@pytest.fixture -def security_handler_factory(): - security_handler_factory = FlaskSecurityHandlerFactory(None) - yield security_handler_factory - - -@pytest.fixture -def app(): - cnx_app = App(__name__, port=5001, - specification_dir=SPEC_FOLDER, debug=True) - cnx_app.add_api('api.yaml', validate_responses=True) - return cnx_app - - -@pytest.fixture -def simple_api_spec_dir(): - return FIXTURES_FOLDER / 'simple' - - -@pytest.fixture -def problem_api_spec_dir(): - return FIXTURES_FOLDER / 'problem' - - -@pytest.fixture -def secure_api_spec_dir(): - return FIXTURES_FOLDER / 'secure_api' - - -@pytest.fixture -def default_param_error_spec_dir(): - return FIXTURES_FOLDER / 'default_param_error' - - -@pytest.fixture -def json_validation_spec_dir(): - return FIXTURES_FOLDER / 'json_validation' - - -@pytest.fixture(scope='session') -def json_datetime_dir(): - return FIXTURES_FOLDER / 'datetime_support' - - -def build_app_from_fixture(api_spec_folder, spec_file='openapi.yaml', **kwargs): - debug = True - if 'debug' in kwargs: - debug = kwargs['debug'] - del (kwargs['debug']) - - cnx_app = App(__name__, - port=5001, - specification_dir=FIXTURES_FOLDER / api_spec_folder, - debug=debug) - - cnx_app.add_api(spec_file, **kwargs) - cnx_app._spec_file = spec_file - return cnx_app - - -@pytest.fixture(scope="session", params=SPECS) -def simple_app(request): - return build_app_from_fixture('simple', request.param, validate_responses=True) - - -@pytest.fixture(scope="session", params=OPENAPI3_SPEC) -def simple_openapi_app(request): - return build_app_from_fixture('simple', request.param, validate_responses=True) - - -@pytest.fixture(scope="session", params=SPECS) -def reverse_proxied_app(request): - - # adapted from http://flask.pocoo.org/snippets/35/ - class ReverseProxied: - - def __init__(self, app, script_name=None, scheme=None, server=None): - self.app = app - self.script_name = script_name - self.scheme = scheme - self.server = server - - def __call__(self, environ, start_response): - script_name = environ.get( - 'HTTP_X_FORWARDED_PATH', '') or self.script_name - if script_name: - environ['SCRIPT_NAME'] = "/" + script_name.lstrip("/") - path_info = environ['PATH_INFO'] - if path_info.startswith(script_name): - environ['PATH_INFO_OLD'] = path_info - environ['PATH_INFO'] = path_info[len(script_name):] - scheme = environ.get('HTTP_X_SCHEME', '') or self.scheme - if scheme: - environ['wsgi.url_scheme'] = scheme - server = environ.get('HTTP_X_FORWARDED_SERVER', '') or self.server - if server: - environ['HTTP_HOST'] = server - return self.app(environ, start_response) - - app = build_app_from_fixture( - 'simple', request.param, validate_responses=True) - flask_app = app.app - proxied = ReverseProxied( - flask_app.wsgi_app, - script_name='/reverse_proxied/' - ) - flask_app.wsgi_app = proxied - return app - - -@pytest.fixture(scope="session", params=SPECS) -def snake_case_app(request): - return build_app_from_fixture('snake_case', request.param, - validate_responses=True, - pythonic_params=True) - - -@pytest.fixture(scope="session", params=SPECS) -def invalid_resp_allowed_app(request): - return build_app_from_fixture('simple', request.param, - validate_responses=False) - - -@pytest.fixture(scope="session", params=SPECS) -def strict_app(request): - return build_app_from_fixture('simple', request.param, - validate_responses=True, - strict_validation=True) - - -@pytest.fixture(scope="session", params=SPECS) -def problem_app(request): - return build_app_from_fixture('problem', request.param, - validate_responses=True) - - -@pytest.fixture(scope="session", params=SPECS) -def schema_app(request): - return build_app_from_fixture('different_schemas', request.param, - validate_responses=True) - - -@pytest.fixture(scope="session", params=SPECS) -def secure_endpoint_app(request): - return build_app_from_fixture('secure_endpoint', request.param, - validate_responses=True, pass_context_arg_name='req_context') - - -@pytest.fixture(scope="session", params=SPECS) -def secure_api_app(request): - options = {"swagger_ui": False} - return build_app_from_fixture('secure_api', request.param, - options=options, auth_all_paths=True) - - -@pytest.fixture(scope="session", params=SPECS) -def unordered_definition_app(request): - return build_app_from_fixture('unordered_definition', request.param) - - -@pytest.fixture(scope="session", params=SPECS) -def bad_operations_app(request): - return build_app_from_fixture('bad_operations', request.param, - resolver_error=501) +import json +import logging +import pathlib +import sys + +import pytest +from firetail import App +from firetail.security import FlaskSecurityHandlerFactory + +logging.basicConfig(level=logging.DEBUG) + +TEST_FOLDER = pathlib.Path(__file__).parent +FIXTURES_FOLDER = TEST_FOLDER / 'fixtures' +SPEC_FOLDER = TEST_FOLDER / "fakeapi" +OPENAPI2_SPEC = ["swagger.yaml"] +OPENAPI3_SPEC = ["openapi.yaml"] +SPECS = OPENAPI2_SPEC + OPENAPI3_SPEC + + +class FakeResponse: + def __init__(self, status_code, text): + """ + :type status_code: int + :type text: ste + """ + self.status_code = status_code + self.text = text + self.ok = status_code == 200 + + def json(self): + return json.loads(self.text) + + +# Helper fixtures functions +# ========================= + + +@pytest.fixture +def oauth_requests(monkeypatch): + def fake_get(url, params=None, headers=None, timeout=None): + """ + :type url: str + :type params: dict| None + """ + headers = headers or {} + if url == "https://oauth.example/token_info": + token = headers.get('Authorization', 'invalid').split()[-1] + if token in ["100", "has_myscope"]: + return FakeResponse(200, '{"uid": "test-user", "scope": ["myscope"]}') + if token in ["200", "has_wrongscope"]: + return FakeResponse(200, '{"uid": "test-user", "scope": ["wrongscope"]}') + if token == "has_myscope_otherscope": + return FakeResponse(200, '{"uid": "test-user", "scope": ["myscope", "otherscope"]}') + if token in ["300", "is_not_invalid"]: + return FakeResponse(404, '') + if token == "has_scopes_in_scopes_with_s": + return FakeResponse(200, '{"uid": "test-user", "scopes": ["myscope", "otherscope"]}') + return url + + monkeypatch.setattr('firetail.security.flask_security_handler_factory.session.get', fake_get) + + +@pytest.fixture +def security_handler_factory(): + security_handler_factory = FlaskSecurityHandlerFactory(None) + yield security_handler_factory + + +@pytest.fixture +def app(): + cnx_app = App(__name__, port=5001, specification_dir=SPEC_FOLDER, debug=True) + cnx_app.add_api('api.yaml', validate_responses=True) + return cnx_app + + +@pytest.fixture +def simple_api_spec_dir(): + return FIXTURES_FOLDER / 'simple' + + +@pytest.fixture(scope='session') +def aiohttp_api_spec_dir(): + return FIXTURES_FOLDER / 'aiohttp' + + +@pytest.fixture +def problem_api_spec_dir(): + return FIXTURES_FOLDER / 'problem' + + +@pytest.fixture +def secure_api_spec_dir(): + return FIXTURES_FOLDER / 'secure_api' + + +@pytest.fixture +def default_param_error_spec_dir(): + return FIXTURES_FOLDER / 'default_param_error' + + +@pytest.fixture +def json_validation_spec_dir(): + return FIXTURES_FOLDER / 'json_validation' + + +@pytest.fixture(scope='session') +def json_datetime_dir(): + return FIXTURES_FOLDER / 'datetime_support' + + +def build_app_from_fixture(api_spec_folder, spec_file='openapi.yaml', **kwargs): + debug = True + if 'debug' in kwargs: + debug = kwargs['debug'] + del (kwargs['debug']) + + cnx_app = App(__name__, + port=5001, + specification_dir=FIXTURES_FOLDER / api_spec_folder, + debug=debug) + + cnx_app.add_api(spec_file, **kwargs) + cnx_app._spec_file = spec_file + return cnx_app + + +@pytest.fixture(scope="session", params=SPECS) +def simple_app(request): + return build_app_from_fixture('simple', request.param, validate_responses=True) + + +@pytest.fixture(scope="session", params=OPENAPI3_SPEC) +def simple_openapi_app(request): + return build_app_from_fixture('simple', request.param, validate_responses=True) + + +@pytest.fixture(scope="session", params=SPECS) +def reverse_proxied_app(request): + + # adapted from http://flask.pocoo.org/snippets/35/ + class ReverseProxied: + + def __init__(self, app, script_name=None, scheme=None, server=None): + self.app = app + self.script_name = script_name + self.scheme = scheme + self.server = server + + def __call__(self, environ, start_response): + script_name = environ.get('HTTP_X_FORWARDED_PATH', '') or self.script_name + if script_name: + environ['SCRIPT_NAME'] = "/" + script_name.lstrip("/") + path_info = environ['PATH_INFO'] + if path_info.startswith(script_name): + environ['PATH_INFO_OLD'] = path_info + environ['PATH_INFO'] = path_info[len(script_name):] + scheme = environ.get('HTTP_X_SCHEME', '') or self.scheme + if scheme: + environ['wsgi.url_scheme'] = scheme + server = environ.get('HTTP_X_FORWARDED_SERVER', '') or self.server + if server: + environ['HTTP_HOST'] = server + return self.app(environ, start_response) + + app = build_app_from_fixture('simple', request.param, validate_responses=True) + flask_app = app.app + proxied = ReverseProxied( + flask_app.wsgi_app, + script_name='/reverse_proxied/' + ) + flask_app.wsgi_app = proxied + return app + + +@pytest.fixture(scope="session", params=SPECS) +def snake_case_app(request): + return build_app_from_fixture('snake_case', request.param, + validate_responses=True, + pythonic_params=True) + + +@pytest.fixture(scope="session", params=SPECS) +def invalid_resp_allowed_app(request): + return build_app_from_fixture('simple', request.param, + validate_responses=False) + + +@pytest.fixture(scope="session", params=SPECS) +def strict_app(request): + return build_app_from_fixture('simple', request.param, + validate_responses=True, + strict_validation=True) + + +@pytest.fixture(scope="session", params=SPECS) +def problem_app(request): + return build_app_from_fixture('problem', request.param, + validate_responses=True) + + +@pytest.fixture(scope="session", params=SPECS) +def schema_app(request): + return build_app_from_fixture('different_schemas', request.param, + validate_responses=True) + + +@pytest.fixture(scope="session", params=SPECS) +def secure_endpoint_app(request): + return build_app_from_fixture('secure_endpoint', request.param, + validate_responses=True, pass_context_arg_name='req_context') + + +@pytest.fixture(scope="session", params=SPECS) +def secure_api_app(request): + options = {"swagger_ui": False} + return build_app_from_fixture('secure_api', request.param, + options=options, auth_all_paths=True) + + +@pytest.fixture(scope="session", params=SPECS) +def unordered_definition_app(request): + return build_app_from_fixture('unordered_definition', request.param) + + +@pytest.fixture(scope="session", params=SPECS) +def bad_operations_app(request): + return build_app_from_fixture('bad_operations', request.param, + resolver_error=501) + + +if sys.version_info < (3, 5, 3) and sys.version_info[0] == 3: + @pytest.fixture + def aiohttp_client(test_client): + return test_client diff --git a/tests/decorators/test_parameter.py b/tests/decorators/test_parameter.py index 310425a..c2d27d5 100644 --- a/tests/decorators/test_parameter.py +++ b/tests/decorators/test_parameter.py @@ -1,28 +1,32 @@ -from unittest.mock import MagicMock - -from firetail.decorators.parameter import parameter_to_arg - - -def test_injection(): - request = MagicMock(name='request', path_params={'p1': '123'}) - request.args = {} - request.headers = {} - request.params = {} - - func = MagicMock() - - def handler(**kwargs): - func(**kwargs) - - class Op: - consumes = ['application/json'] - - def get_arguments(self, *args, **kwargs): - return {"p1": "123"} - - parameter_to_arg(Op(), handler)(request) - func.assert_called_with(p1='123') - - parameter_to_arg( - Op(), handler, pass_context_arg_name='framework_request_ctx')(request) - func.assert_called_with(p1='123', framework_request_ctx=request.context) +from unittest.mock import MagicMock + +from firetail.decorators.parameter import parameter_to_arg, pythonic + + +def test_injection(): + request = MagicMock(name='request', path_params={'p1': '123'}) + request.args = {} + request.headers = {} + request.params = {} + + func = MagicMock() + + def handler(**kwargs): + func(**kwargs) + + class Op: + consumes = ['application/json'] + + def get_arguments(self, *args, **kwargs): + return {"p1": "123"} + + parameter_to_arg(Op(), handler)(request) + func.assert_called_with(p1='123') + + parameter_to_arg(Op(), handler, pass_context_arg_name='framework_request_ctx')(request) + func.assert_called_with(p1='123', framework_request_ctx=request.context) + + +def test_pythonic_params(): + assert pythonic('orderBy[eq]') == 'order_by_eq' + assert pythonic('ids[]') == 'ids' diff --git a/tests/decorators/test_security.py b/tests/decorators/test_security.py index bdaff40..8b6848a 100644 --- a/tests/decorators/test_security.py +++ b/tests/decorators/test_security.py @@ -1,260 +1,244 @@ -import json -from unittest.mock import MagicMock - -import pytest -import requests -from firetail.exceptions import (BadRequestProblem, FiretailException, - OAuthProblem, OAuthResponseProblem, - OAuthScopeProblem) - - -def test_get_tokeninfo_url(monkeypatch, security_handler_factory): - security_handler_factory.get_token_info_remote = MagicMock( - return_value='get_token_info_remote_result') - env = {} - monkeypatch.setattr('os.environ', env) - logger = MagicMock() - monkeypatch.setattr( - 'firetail.security.security_handler_factory.logger', logger) - - security_def = {} - assert security_handler_factory.get_tokeninfo_func(security_def) is None - logger.warn.assert_not_called() - - env['TOKENINFO_URL'] = 'issue-146' - assert security_handler_factory.get_tokeninfo_func( - security_def) == 'get_token_info_remote_result' - security_handler_factory.get_token_info_remote.assert_called_with( - 'issue-146') - logger.warn.assert_not_called() - logger.warn.reset_mock() - - security_def = {'x-tokenInfoUrl': 'bar'} - assert security_handler_factory.get_tokeninfo_func( - security_def) == 'get_token_info_remote_result' - security_handler_factory.get_token_info_remote.assert_called_with('bar') - logger.warn.assert_not_called() - - -def test_verify_oauth_missing_auth_header(security_handler_factory): - def somefunc(token): - return None - - wrapped_func = security_handler_factory.verify_oauth( - somefunc, security_handler_factory.validate_scope, ['admin']) - - request = MagicMock() - request.headers = {} - - assert wrapped_func(request) is security_handler_factory.no_value - - -def test_verify_oauth_scopes_remote(monkeypatch, security_handler_factory): - tokeninfo = dict(uid="foo", scope="scope1 scope2") - - def get_tokeninfo_response(*args, **kwargs): - tokeninfo_response = requests.Response() - tokeninfo_response.status_code = requests.codes.ok - tokeninfo_response._content = json.dumps(tokeninfo).encode() - return tokeninfo_response - - token_info_func = security_handler_factory.get_tokeninfo_func( - {'x-tokenInfoUrl': 'https://example.org/tokeninfo'}) - wrapped_func = security_handler_factory.verify_oauth( - token_info_func, security_handler_factory.validate_scope, ['admin']) - - request = MagicMock() - request.headers = {"Authorization": "Bearer 123"} - - session = MagicMock() - session.get = get_tokeninfo_response - monkeypatch.setattr( - 'firetail.security.flask_security_handler_factory.session', session) - - with pytest.raises(OAuthScopeProblem, match="Provided token doesn't have the required scope"): - wrapped_func(request) - - tokeninfo["scope"] += " admin" - assert wrapped_func(request) is not None - - tokeninfo["scope"] = ["foo", "bar"] - with pytest.raises(OAuthScopeProblem, match="Provided token doesn't have the required scope"): - wrapped_func(request) - - tokeninfo["scope"].append("admin") - assert wrapped_func(request) is not None - - -def test_verify_oauth_invalid_local_token_response_none(security_handler_factory): - def somefunc(token): - return None - - wrapped_func = security_handler_factory.verify_oauth( - somefunc, security_handler_factory.validate_scope, ['admin']) - - request = MagicMock() - request.headers = {"Authorization": "Bearer 123"} - - with pytest.raises(OAuthResponseProblem): - wrapped_func(request) - - -def test_verify_oauth_scopes_local(security_handler_factory): - tokeninfo = dict(uid="foo", scope="scope1 scope2") - - def token_info(token): - return tokeninfo - - wrapped_func = security_handler_factory.verify_oauth( - token_info, security_handler_factory.validate_scope, ['admin']) - - request = MagicMock() - request.headers = {"Authorization": "Bearer 123"} - - with pytest.raises(OAuthScopeProblem, match="Provided token doesn't have the required scope"): - wrapped_func(request) - - tokeninfo["scope"] += " admin" - assert wrapped_func(request) is not None - - tokeninfo["scope"] = ["foo", "bar"] - with pytest.raises(OAuthScopeProblem, match="Provided token doesn't have the required scope"): - wrapped_func(request) - - tokeninfo["scope"].append("admin") - assert wrapped_func(request) is not None - - -def test_verify_basic_missing_auth_header(security_handler_factory): - def somefunc(username, password, required_scopes=None): - return None - - wrapped_func = security_handler_factory.verify_basic(somefunc) - - request = MagicMock() - request.headers = {"Authorization": "Bearer 123"} - - assert wrapped_func(request) is security_handler_factory.no_value - - -def test_verify_basic(security_handler_factory): - def basic_info(username, password, required_scopes=None): - if username == 'foo' and password == 'bar': - return {'sub': 'foo'} - return None - - wrapped_func = security_handler_factory.verify_basic(basic_info) - - request = MagicMock() - request.headers = {"Authorization": 'Basic Zm9vOmJhcg=='} - - assert wrapped_func(request) is not None - - -def test_verify_apikey_query(security_handler_factory): - def apikey_info(apikey, required_scopes=None): - if apikey == 'foobar': - return {'sub': 'foo'} - return None - - wrapped_func = security_handler_factory.verify_api_key( - apikey_info, 'query', 'auth') - - request = MagicMock() - request.query = {"auth": 'foobar'} - - assert wrapped_func(request) is not None - - -def test_verify_apikey_header(security_handler_factory): - def apikey_info(apikey, required_scopes=None): - if apikey == 'foobar': - return {'sub': 'foo'} - return None - - wrapped_func = security_handler_factory.verify_api_key( - apikey_info, 'header', 'X-Auth') - - request = MagicMock() - request.headers = {"X-Auth": 'foobar'} - - assert wrapped_func(request) is not None - - -def test_multiple_schemes(security_handler_factory): - def apikey1_info(apikey, required_scopes=None): - if apikey == 'foobar': - return {'sub': 'foo'} - return None - - def apikey2_info(apikey, required_scopes=None): - if apikey == 'bar': - return {'sub': 'bar'} - return None - - wrapped_func_key1 = security_handler_factory.verify_api_key( - apikey1_info, 'header', 'X-Auth-1') - wrapped_func_key2 = security_handler_factory.verify_api_key( - apikey2_info, 'header', 'X-Auth-2') - schemes = { - 'key1': wrapped_func_key1, - 'key2': wrapped_func_key2, - } - wrapped_func = security_handler_factory.verify_multiple_schemes(schemes) - - # Single key does not succeed - request = MagicMock() - request.headers = {"X-Auth-1": 'foobar'} - - assert wrapped_func(request) is security_handler_factory.no_value - - request = MagicMock() - request.headers = {"X-Auth-2": 'bar'} - - assert wrapped_func(request) is security_handler_factory.no_value - - # Supplying both keys does succeed - request = MagicMock() - request.headers = { - "X-Auth-1": 'foobar', - "X-Auth-2": 'bar' - } - - expected_token_info = { - 'key1': {'sub': 'foo'}, - 'key2': {'sub': 'bar'}, - } - assert wrapped_func(request) == expected_token_info - - -def test_verify_security_oauthproblem(security_handler_factory): - """Tests whether verify_security raises an OAuthProblem if there are no auth_funcs.""" - func_to_secure = MagicMock(return_value='func') - secured_func = security_handler_factory.verify_security([], func_to_secure) - - request = MagicMock() - with pytest.raises(OAuthProblem) as exc_info: - secured_func(request) - - assert str( - exc_info.value) == '401 Unauthorized: No authorization token provided' - - -@pytest.mark.parametrize( - 'errors, most_specific', - [ - ([OAuthProblem()], OAuthProblem), - ([OAuthProblem(), OAuthScopeProblem([], [])], OAuthScopeProblem), - ([OAuthProblem(), OAuthScopeProblem([], []), - BadRequestProblem], OAuthScopeProblem), - ([OAuthProblem(), OAuthScopeProblem([], []), - BadRequestProblem, FiretailException], OAuthScopeProblem), - ([BadRequestProblem(), FiretailException()], BadRequestProblem), - ([FiretailException()], FiretailException), - ] -) -def test_raise_most_specific(errors, most_specific, security_handler_factory): - """Tests whether most specific exception is raised from a list.""" - - with pytest.raises(most_specific): - security_handler_factory._raise_most_specific(errors) +import json +from unittest.mock import MagicMock + +import pytest +import requests +from firetail.exceptions import ( + BadRequestProblem, + FiretailException, + OAuthProblem, + OAuthResponseProblem, + OAuthScopeProblem, +) + + +def test_get_tokeninfo_url(monkeypatch, security_handler_factory): + security_handler_factory.get_token_info_remote = MagicMock(return_value='get_token_info_remote_result') + env = {} + monkeypatch.setattr('os.environ', env) + logger = MagicMock() + monkeypatch.setattr('firetail.security.security_handler_factory.logger', logger) + + security_def = {} + assert security_handler_factory.get_tokeninfo_func(security_def) is None + logger.warn.assert_not_called() + + env['TOKENINFO_URL'] = 'issue-146' + assert security_handler_factory.get_tokeninfo_func(security_def) == 'get_token_info_remote_result' + security_handler_factory.get_token_info_remote.assert_called_with('issue-146') + logger.warn.assert_not_called() + logger.warn.reset_mock() + + security_def = {'x-tokenInfoUrl': 'bar'} + assert security_handler_factory.get_tokeninfo_func(security_def) == 'get_token_info_remote_result' + security_handler_factory.get_token_info_remote.assert_called_with('bar') + logger.warn.assert_not_called() + + +def test_verify_oauth_missing_auth_header(security_handler_factory): + def somefunc(token): + return None + + wrapped_func = security_handler_factory.verify_oauth(somefunc, security_handler_factory.validate_scope, ['admin']) + + request = MagicMock() + request.headers = {} + + assert wrapped_func(request) is security_handler_factory.no_value + + +def test_verify_oauth_scopes_remote(monkeypatch, security_handler_factory): + tokeninfo = dict(uid="foo", scope="scope1 scope2") + + def get_tokeninfo_response(*args, **kwargs): + tokeninfo_response = requests.Response() + tokeninfo_response.status_code = requests.codes.ok + tokeninfo_response._content = json.dumps(tokeninfo).encode() + return tokeninfo_response + + token_info_func = security_handler_factory.get_tokeninfo_func({'x-tokenInfoUrl': 'https://example.org/tokeninfo'}) + wrapped_func = security_handler_factory.verify_oauth(token_info_func, security_handler_factory.validate_scope, ['admin']) + + request = MagicMock() + request.headers = {"Authorization": "Bearer 123"} + + session = MagicMock() + session.get = get_tokeninfo_response + monkeypatch.setattr('firetail.security.flask_security_handler_factory.session', session) + + with pytest.raises(OAuthScopeProblem, match="Provided token doesn't have the required scope"): + wrapped_func(request) + + tokeninfo["scope"] += " admin" + assert wrapped_func(request) is not None + + tokeninfo["scope"] = ["foo", "bar"] + with pytest.raises(OAuthScopeProblem, match="Provided token doesn't have the required scope"): + wrapped_func(request) + + tokeninfo["scope"].append("admin") + assert wrapped_func(request) is not None + + +def test_verify_oauth_invalid_local_token_response_none(security_handler_factory): + def somefunc(token): + return None + + wrapped_func = security_handler_factory.verify_oauth(somefunc, security_handler_factory.validate_scope, ['admin']) + + request = MagicMock() + request.headers = {"Authorization": "Bearer 123"} + + with pytest.raises(OAuthResponseProblem): + wrapped_func(request) + + +def test_verify_oauth_scopes_local(security_handler_factory): + tokeninfo = dict(uid="foo", scope="scope1 scope2") + + def token_info(token): + return tokeninfo + + wrapped_func = security_handler_factory.verify_oauth(token_info, security_handler_factory.validate_scope, ['admin']) + + request = MagicMock() + request.headers = {"Authorization": "Bearer 123"} + + with pytest.raises(OAuthScopeProblem, match="Provided token doesn't have the required scope"): + wrapped_func(request) + + tokeninfo["scope"] += " admin" + assert wrapped_func(request) is not None + + tokeninfo["scope"] = ["foo", "bar"] + with pytest.raises(OAuthScopeProblem, match="Provided token doesn't have the required scope"): + wrapped_func(request) + + tokeninfo["scope"].append("admin") + assert wrapped_func(request) is not None + + +def test_verify_basic_missing_auth_header(security_handler_factory): + def somefunc(username, password, required_scopes=None): + return None + + wrapped_func = security_handler_factory.verify_basic(somefunc) + + request = MagicMock() + request.headers = {"Authorization": "Bearer 123"} + + assert wrapped_func(request) is security_handler_factory.no_value + + +def test_verify_basic(security_handler_factory): + def basic_info(username, password, required_scopes=None): + if username == 'foo' and password == 'bar': + return {'sub': 'foo'} + return None + + wrapped_func = security_handler_factory.verify_basic(basic_info) + + request = MagicMock() + request.headers = {"Authorization": 'Basic Zm9vOmJhcg=='} + + assert wrapped_func(request) is not None + + +def test_verify_apikey_query(security_handler_factory): + def apikey_info(apikey, required_scopes=None): + if apikey == 'foobar': + return {'sub': 'foo'} + return None + + wrapped_func = security_handler_factory.verify_api_key(apikey_info, 'query', 'auth') + + request = MagicMock() + request.query = {"auth": 'foobar'} + + assert wrapped_func(request) is not None + + +def test_verify_apikey_header(security_handler_factory): + def apikey_info(apikey, required_scopes=None): + if apikey == 'foobar': + return {'sub': 'foo'} + return None + + wrapped_func = security_handler_factory.verify_api_key(apikey_info, 'header', 'X-Auth') + + request = MagicMock() + request.headers = {"X-Auth": 'foobar'} + + assert wrapped_func(request) is not None + + +def test_multiple_schemes(security_handler_factory): + def apikey1_info(apikey, required_scopes=None): + if apikey == 'foobar': + return {'sub': 'foo'} + return None + def apikey2_info(apikey, required_scopes=None): + if apikey == 'bar': + return {'sub': 'bar'} + return None + + wrapped_func_key1 = security_handler_factory.verify_api_key(apikey1_info, 'header', 'X-Auth-1') + wrapped_func_key2 = security_handler_factory.verify_api_key(apikey2_info, 'header', 'X-Auth-2') + schemes = { + 'key1': wrapped_func_key1, + 'key2': wrapped_func_key2, + } + wrapped_func = security_handler_factory.verify_multiple_schemes(schemes) + + # Single key does not succeed + request = MagicMock() + request.headers = {"X-Auth-1": 'foobar'} + + assert wrapped_func(request) is security_handler_factory.no_value + + request = MagicMock() + request.headers = {"X-Auth-2": 'bar'} + + assert wrapped_func(request) is security_handler_factory.no_value + + # Supplying both keys does succeed + request = MagicMock() + request.headers = { + "X-Auth-1": 'foobar', + "X-Auth-2": 'bar' + } + + expected_token_info = { + 'key1': {'sub': 'foo'}, + 'key2': {'sub': 'bar'}, + } + assert wrapped_func(request) == expected_token_info + + +def test_verify_security_oauthproblem(security_handler_factory): + """Tests whether verify_security raises an OAuthProblem if there are no auth_funcs.""" + func_to_secure = MagicMock(return_value='func') + secured_func = security_handler_factory.verify_security([], func_to_secure) + + request = MagicMock() + with pytest.raises(OAuthProblem) as exc_info: + secured_func(request) + + assert str(exc_info.value) == '401 Unauthorized: No authorization token provided' + +@pytest.mark.parametrize( + 'errors, most_specific', + [ + ([OAuthProblem()], OAuthProblem), + ([OAuthProblem(), OAuthScopeProblem([], [])], OAuthScopeProblem), + ([OAuthProblem(), OAuthScopeProblem([], []), BadRequestProblem], OAuthScopeProblem), + ([OAuthProblem(), OAuthScopeProblem([], []), BadRequestProblem, FiretailException], OAuthScopeProblem), + ([BadRequestProblem(), FiretailException()], BadRequestProblem), + ([FiretailException()], FiretailException), + ] +) +def test_raise_most_specific(errors, most_specific, security_handler_factory): + """Tests whether most specific exception is raised from a list.""" + + with pytest.raises(most_specific): + security_handler_factory._raise_most_specific(errors) diff --git a/tests/decorators/test_uri_parsing.py b/tests/decorators/test_uri_parsing.py index 229c11e..4160b43 100644 --- a/tests/decorators/test_uri_parsing.py +++ b/tests/decorators/test_uri_parsing.py @@ -1,137 +1,200 @@ -import pytest -from firetail.decorators.uri_parsing import (AlwaysMultiURIParser, - FirstValueURIParser, - OpenAPIURIParser, - Swagger2URIParser) -from werkzeug.datastructures import MultiDict - -QUERY1 = MultiDict([("letters", "a"), ("letters", "b,c"), - ("letters", "d,e,f")]) -QUERY2 = MultiDict([("letters", "a"), ("letters", "b|c"), - ("letters", "d|e|f")]) -QUERY3 = MultiDict([("letters[eq]", ["a"]), ("letters[eq]", ["b", "c"]), - ("letters[eq]", ["d", "e", "f"])]) -QUERY4 = MultiDict([("letters[eq]", "a"), ("letters[eq]", "b|c"), - ("letters[eq]", "d|e|f")]) -QUERY5 = MultiDict([("letters[eq]", "a"), ("letters[eq]", "b,c"), - ("letters[eq]", "d,e,f")]) -PATH1 = {"letters": "d,e,f"} -PATH2 = {"letters": "d|e|f"} -CSV = "csv" -PIPES = "pipes" -MULTI = "multi" - - -@pytest.mark.parametrize("parser_class, expected, query_in, collection_format", [ - (Swagger2URIParser, ['d', 'e', 'f'], QUERY1, CSV), - (FirstValueURIParser, ['a'], QUERY1, CSV), - (AlwaysMultiURIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY1, CSV), - (Swagger2URIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY1, MULTI), - (FirstValueURIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY1, MULTI), - (AlwaysMultiURIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY1, MULTI), - (Swagger2URIParser, ['d', 'e', 'f'], QUERY2, PIPES), - (FirstValueURIParser, ['a'], QUERY2, PIPES), - (AlwaysMultiURIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY2, PIPES)]) -def test_uri_parser_query_params(parser_class, expected, query_in, collection_format): - class Request: - query = query_in - path_params = {} - form = {} - - request = Request() - parameters = [ - {"name": "letters", - "in": "query", - "type": "array", - "items": {"type": "string"}, - "collectionFormat": collection_format} - ] - body_defn = {} - p = parser_class(parameters, body_defn) - res = p(lambda x: x)(request) - assert res.query["letters"] == expected - - -@pytest.mark.parametrize("parser_class, expected, query_in, collection_format", [ - (Swagger2URIParser, ['d', 'e', 'f'], QUERY1, CSV), - (FirstValueURIParser, ['a'], QUERY1, CSV), - (AlwaysMultiURIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY1, CSV), - (Swagger2URIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY1, MULTI), - (FirstValueURIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY1, MULTI), - (AlwaysMultiURIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY1, MULTI), - (Swagger2URIParser, ['d', 'e', 'f'], QUERY2, PIPES), - (FirstValueURIParser, ['a'], QUERY2, PIPES), - (AlwaysMultiURIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY2, PIPES)]) -def test_uri_parser_form_params(parser_class, expected, query_in, collection_format): - class Request: - query = {} - form = query_in - path_params = {} - - request = Request() - parameters = [ - {"name": "letters", - "in": "formData", - "type": "array", - "items": {"type": "string"}, - "collectionFormat": collection_format} - ] - body_defn = {} - p = parser_class(parameters, body_defn) - res = p(lambda x: x)(request) - assert res.form["letters"] == expected - - -@pytest.mark.parametrize("parser_class, expected, query_in, collection_format", [ - (Swagger2URIParser, ['d', 'e', 'f'], PATH1, CSV), - (FirstValueURIParser, ['d', 'e', 'f'], PATH1, CSV), - (AlwaysMultiURIParser, ['d', 'e', 'f'], PATH1, CSV), - (Swagger2URIParser, ['d', 'e', 'f'], PATH2, PIPES), - (FirstValueURIParser, ['d', 'e', 'f'], PATH2, PIPES), - (AlwaysMultiURIParser, ['d', 'e', 'f'], PATH2, PIPES)]) -def test_uri_parser_path_params(parser_class, expected, query_in, collection_format): - class Request: - query = {} - form = {} - path_params = query_in - - request = Request() - parameters = [ - {"name": "letters", - "in": "path", - "type": "array", - "items": {"type": "string"}, - "collectionFormat": collection_format} - ] - body_defn = {} - p = parser_class(parameters, body_defn) - res = p(lambda x: x)(request) - assert res.path_params["letters"] == expected - - -@pytest.mark.parametrize("parser_class, expected, query_in, collection_format", [ - (OpenAPIURIParser, ['d', 'e', 'f'], QUERY3, None), - (Swagger2URIParser, ['d', 'e', 'f'], QUERY5, CSV), - (FirstValueURIParser, ['a'], QUERY5, CSV), - (AlwaysMultiURIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY5, CSV), - (Swagger2URIParser, ['d', 'e', 'f'], QUERY4, PIPES), - (FirstValueURIParser, ['a'], QUERY4, PIPES), - (AlwaysMultiURIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY4, PIPES)]) -def test_uri_parser_query_params_with_square_brackets(parser_class, expected, query_in, collection_format): - class Request: - query = query_in - path_params = {} - form = {} - - request = Request() - parameters = [ - {"name": "letters[eq]", - "in": "query", - "type": "array", - "items": {"type": "string"}, - "collectionFormat": collection_format} - ] - body_defn = {} - p = parser_class(parameters, body_defn) - res = p(lambda x: x)(request) - assert res.query["letters[eq]"] == expected +import pytest +from firetail.decorators.uri_parsing import ( + AlwaysMultiURIParser, + FirstValueURIParser, + OpenAPIURIParser, + Swagger2URIParser, +) +from werkzeug.datastructures import MultiDict + +QUERY1 = MultiDict([("letters", "a"), ("letters", "b,c"), + ("letters", "d,e,f")]) +QUERY2 = MultiDict([("letters", "a"), ("letters", "b|c"), + ("letters", "d|e|f")]) + +QUERY3 = MultiDict([("letters[eq]", ["a"]), ("letters[eq]", ["b", "c"]), + ("letters[eq]", ["d", "e", "f"])]) +QUERY4 = MultiDict([("letters[eq]", "a"), ("letters[eq]", "b|c"), + ("letters[eq]", "d|e|f")]) +QUERY5 = MultiDict([("letters[eq]", "a"), ("letters[eq]", "b,c"), + ("letters[eq]", "d,e,f")]) + +QUERY6 = MultiDict([("letters_eq", "a")]) +PATH1 = {"letters": "d,e,f"} +PATH2 = {"letters": "d|e|f"} +CSV = "csv" +PIPES = "pipes" +MULTI = "multi" + + +@pytest.mark.parametrize("parser_class, expected, query_in, collection_format", [ + (Swagger2URIParser, ['d', 'e', 'f'], QUERY1, CSV), + (FirstValueURIParser, ['a'], QUERY1, CSV), + (AlwaysMultiURIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY1, CSV), + (Swagger2URIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY1, MULTI), + (FirstValueURIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY1, MULTI), + (AlwaysMultiURIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY1, MULTI), + (Swagger2URIParser, ['d', 'e', 'f'], QUERY2, PIPES), + (FirstValueURIParser, ['a'], QUERY2, PIPES), + (AlwaysMultiURIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY2, PIPES)]) +def test_uri_parser_query_params(parser_class, expected, query_in, collection_format): + class Request: + query = query_in + path_params = {} + form = {} + + request = Request() + parameters = [ + {"name": "letters", + "in": "query", + "type": "array", + "items": {"type": "string"}, + "collectionFormat": collection_format} + ] + body_defn = {} + p = parser_class(parameters, body_defn) + res = p(lambda x: x)(request) + assert res.query["letters"] == expected + + +@pytest.mark.parametrize("parser_class, expected, query_in, collection_format", [ + (Swagger2URIParser, ['d', 'e', 'f'], QUERY1, CSV), + (FirstValueURIParser, ['a'], QUERY1, CSV), + (AlwaysMultiURIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY1, CSV), + (Swagger2URIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY1, MULTI), + (FirstValueURIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY1, MULTI), + (AlwaysMultiURIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY1, MULTI), + (Swagger2URIParser, ['d', 'e', 'f'], QUERY2, PIPES), + (FirstValueURIParser, ['a'], QUERY2, PIPES), + (AlwaysMultiURIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY2, PIPES)]) +def test_uri_parser_form_params(parser_class, expected, query_in, collection_format): + class Request: + query = {} + form = query_in + path_params = {} + + request = Request() + parameters = [ + {"name": "letters", + "in": "formData", + "type": "array", + "items": {"type": "string"}, + "collectionFormat": collection_format} + ] + body_defn = {} + p = parser_class(parameters, body_defn) + res = p(lambda x: x)(request) + assert res.form["letters"] == expected + + +@pytest.mark.parametrize("parser_class, expected, query_in, collection_format", [ + (Swagger2URIParser, ['d', 'e', 'f'], PATH1, CSV), + (FirstValueURIParser, ['d', 'e', 'f'], PATH1, CSV), + (AlwaysMultiURIParser, ['d', 'e', 'f'], PATH1, CSV), + (Swagger2URIParser, ['d', 'e', 'f'], PATH2, PIPES), + (FirstValueURIParser, ['d', 'e', 'f'], PATH2, PIPES), + (AlwaysMultiURIParser, ['d', 'e', 'f'], PATH2, PIPES)]) +def test_uri_parser_path_params(parser_class, expected, query_in, collection_format): + class Request: + query = {} + form = {} + path_params = query_in + + request = Request() + parameters = [ + {"name": "letters", + "in": "path", + "type": "array", + "items": {"type": "string"}, + "collectionFormat": collection_format} + ] + body_defn = {} + p = parser_class(parameters, body_defn) + res = p(lambda x: x)(request) + assert res.path_params["letters"] == expected + + +@pytest.mark.parametrize("parser_class, expected, query_in, collection_format", [ + (OpenAPIURIParser, ['d', 'e', 'f'], QUERY3, None), + (Swagger2URIParser, ['d', 'e', 'f'], QUERY5, CSV), + (FirstValueURIParser, ['a'], QUERY5, CSV), + (AlwaysMultiURIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY5, CSV), + (Swagger2URIParser, ['d', 'e', 'f'], QUERY4, PIPES), + (FirstValueURIParser, ['a'], QUERY4, PIPES), + (AlwaysMultiURIParser, ['a', 'b', 'c', 'd', 'e', 'f'], QUERY4, PIPES)]) +def test_uri_parser_query_params_with_square_brackets(parser_class, expected, query_in, collection_format): + class Request: + query = query_in + path_params = {} + form = {} + + request = Request() + parameters = [ + {"name": "letters[eq]", + "in": "query", + "type": "array", + "items": {"type": "string"}, + "collectionFormat": collection_format} + ] + body_defn = {} + p = parser_class(parameters, body_defn) + res = p(lambda x: x)(request) + assert res.query["letters[eq]"] == expected + +@pytest.mark.parametrize("parser_class, expected, query_in, collection_format", [ + (OpenAPIURIParser, ['a'], QUERY6, CSV), + (Swagger2URIParser, ['a'], QUERY6, CSV), + (FirstValueURIParser, ['a'], QUERY6, CSV), + (AlwaysMultiURIParser, ['a'], QUERY6, CSV), + (Swagger2URIParser, ['a'], QUERY6, MULTI), + (FirstValueURIParser, ['a'], QUERY6, MULTI), + (AlwaysMultiURIParser, ['a'], QUERY6, MULTI), + (Swagger2URIParser, ['a'], QUERY6, PIPES), + (FirstValueURIParser, ['a'], QUERY6, PIPES), + (AlwaysMultiURIParser, ['a'], QUERY6, PIPES)]) +def test_uri_parser_query_params_with_underscores(parser_class, expected, query_in, collection_format): + class Request: + query = query_in + path_params = {} + form = {} + + request = Request() + parameters = [ + {"name": "letters", + "in": "query", + "type": "string", + "items": {"type": "string"}, + "collectionFormat": collection_format} + ] + body_defn = {} + p = parser_class(parameters, body_defn) + res = p(lambda x: x)(request) + assert res.query["letters_eq"] == expected + + +@pytest.mark.parametrize("parser_class, query_in, collection_format, explode, expected", [ + (OpenAPIURIParser, MultiDict([("letters[eq]_unrelated", "a")]), None, False,{"letters[eq]_unrelated": ["a"]}), + (OpenAPIURIParser, MultiDict([("letters[eq][unrelated]", "a")]), "csv", True,{"letters[eq][unrelated]": ["a"]}), + ]) +def test_uri_parser_query_params_with_malformed_names(parser_class, query_in, collection_format, explode, expected): + class Request: + query = query_in + path_params = {} + form = {} + + request = Request() + parameters = [ + {"name": "letters[eq]", + "in": "query", + "explode": explode, + "collectionFormat": collection_format, + "schema": { + "type": "array", + "items": {"type": "string"}, + } + } + ] + body_defn = {} + p = parser_class(parameters, body_defn) + res = p(lambda x: x)(request) + assert res.query == expected diff --git a/tests/decorators/test_validation.py b/tests/decorators/test_validation.py index e671b65..0b14cee 100644 --- a/tests/decorators/test_validation.py +++ b/tests/decorators/test_validation.py @@ -1,205 +1,200 @@ -from unittest.mock import MagicMock - -import pytest -from firetail.apis.flask_api import FlaskApi -from firetail.decorators.validation import ParameterValidator -from firetail.json_schema import (Draft4RequestValidator, - Draft4ResponseValidator) -from jsonschema import ValidationError - - -def test_get_valid_parameter(): - result = ParameterValidator.validate_parameter( - 'formdata', 20, {'type': 'number', 'name': 'foobar'}) - assert result is None - - -def test_get_valid_parameter_with_required_attr(): - param = {'type': 'number', 'required': True, 'name': 'foobar'} - result = ParameterValidator.validate_parameter('formdata', 20, param) - assert result is None - - -def test_get_valid_path_parameter(): - param = {'required': True, 'schema': {'type': 'number'}, 'name': 'foobar'} - result = ParameterValidator.validate_parameter('path', 20, param) - assert result is None - - -def test_get_missing_required_parameter(): - param = {'type': 'number', 'required': True, 'name': 'foo'} - result = ParameterValidator.validate_parameter('formdata', None, param) - assert result == "Missing formdata parameter 'foo'" - - -def test_get_x_nullable_parameter(): - param = {'type': 'number', 'required': True, - 'name': 'foo', 'x-nullable': True} - result = ParameterValidator.validate_parameter('formdata', 'None', param) - assert result is None - - -def test_get_nullable_parameter(): - param = {'schema': {'type': 'number', 'nullable': True}, - 'required': True, 'name': 'foo'} - result = ParameterValidator.validate_parameter('query', 'null', param) - assert result is None - - -def test_get_explodable_object_parameter(): - param = {'schema': {'type': 'object', 'additionalProperties': True}, - 'required': True, 'name': 'foo', 'style': 'deepObject', 'explode': True} - result = ParameterValidator.validate_parameter('query', {'bar': 1}, param) - assert result is None - - -def test_get_valid_parameter_with_enum_array_header(): - value = 'VALUE1,VALUE2' - param = {'schema': {'type': 'array', 'items': {'type': 'string', 'enum': ['VALUE1', 'VALUE2']}}, - 'name': 'test_header_param'} - result = ParameterValidator.validate_parameter('header', value, param) - assert result is None - - -def test_invalid_type(monkeypatch): - logger = MagicMock() - monkeypatch.setattr('firetail.decorators.validation.logger', logger) - result = ParameterValidator.validate_parameter( - 'formdata', 20, {'type': 'string', 'name': 'foo'}) - expected_result = """20 is not of type 'string' - -Failed validating 'type' in schema: - {'name': 'foo', 'type': 'string'} - -On instance: - 20""" - assert result == expected_result - logger.info.assert_called_once() - - -def test_invalid_type_value_error(monkeypatch): - logger = MagicMock() - monkeypatch.setattr('firetail.decorators.validation.logger', logger) - value = {'test': 1, 'second': 2} - result = ParameterValidator.validate_parameter( - 'formdata', value, {'type': 'boolean', 'name': 'foo'}) - assert result == "Wrong type, expected 'boolean' for formdata parameter 'foo'" - - -def test_enum_error(monkeypatch): - logger = MagicMock() - monkeypatch.setattr('firetail.decorators.validation.logger', logger) - value = 'INVALID' - param = {'schema': {'type': 'string', 'enum': ['valid']}, - 'name': 'test_path_param'} - result = ParameterValidator.validate_parameter('path', value, param) - assert result.startswith("'INVALID' is not one of ['valid']") - - -def test_support_nullable_properties(): - schema = { - "type": "object", - "properties": {"foo": {"type": "string", "x-nullable": True}}, - } - try: - Draft4RequestValidator(schema).validate({"foo": None}) - except ValidationError: - pytest.fail("Shouldn't raise ValidationError") - - -def test_support_nullable_properties_raises_validation_error(): - schema = { - "type": "object", - "properties": {"foo": {"type": "string", "x-nullable": False}}, - } - - with pytest.raises(ValidationError): - Draft4RequestValidator(schema).validate({"foo": None}) - - -def test_support_nullable_properties_not_iterable(): - schema = { - "type": "object", - "properties": {"foo": {"type": "string", "x-nullable": True}}, - } - with pytest.raises(ValidationError): - Draft4RequestValidator(schema).validate(12345) - - -def test_nullable_enum(): - schema = { - "enum": ["foo", 7], - "nullable": True - } - try: - Draft4RequestValidator(schema).validate(None) - except ValidationError: - pytest.fail("Shouldn't raise ValidationError") - - -def test_nullable_enum_error(): - schema = { - "enum": ["foo", 7] - } - with pytest.raises(ValidationError): - Draft4RequestValidator(schema).validate(None) - - -def test_writeonly_value(): - schema = { - "type": "object", - "properties": {"foo": {"type": "string", "writeOnly": True}}, - } - try: - Draft4RequestValidator(schema).validate({"foo": "bar"}) - except ValidationError: - pytest.fail("Shouldn't raise ValidationError") - - -def test_writeonly_value_error(): - schema = { - "type": "object", - "properties": {"foo": {"type": "string", "writeOnly": True}}, - } - with pytest.raises(ValidationError): - Draft4ResponseValidator(schema).validate({"foo": "bar"}) - - -def test_writeonly_required(): - schema = { - "type": "object", - "required": ["foo"], - "properties": {"foo": {"type": "string", "writeOnly": True}}, - } - try: - Draft4RequestValidator(schema).validate({"foo": "bar"}) - except ValidationError: - pytest.fail("Shouldn't raise ValidationError") - - -def test_writeonly_required_error(): - schema = { - "type": "object", - "required": ["foo"], - "properties": {"foo": {"type": "string", "writeOnly": True}}, - } - with pytest.raises(ValidationError): - Draft4RequestValidator(schema).validate({"bar": "baz"}) - - -def test_formdata_extra_parameter_strict(): - """Tests that firetail handles explicitly defined formData parameters well across Swagger 2 - and OpenApi 3. In Swagger 2, any formData parameter should be defined explicitly, while in - OpenAPI 3 this is not allowed. See issues #1020 #1160 #1340 #1343.""" - request = MagicMock(form={'param': 'value', 'extra_param': 'extra_value'}) - - # OAS3 - validator = ParameterValidator([], FlaskApi, strict_validation=True) - errors = validator.validate_formdata_parameter_list(request) - assert not errors - - # Swagger 2 - validator = ParameterValidator([{'in': 'formData', 'name': 'param'}], FlaskApi, - strict_validation=True) - errors = validator.validate_formdata_parameter_list(request) - assert errors +from unittest.mock import MagicMock + +import pytest +from firetail.apis.flask_api import FlaskApi +from firetail.decorators.validation import ParameterValidator +from firetail.json_schema import Draft4RequestValidator, Draft4ResponseValidator +from jsonschema import ValidationError + + +def test_get_valid_parameter(): + result = ParameterValidator.validate_parameter('formdata', 20, {'type': 'number', 'name': 'foobar'}) + assert result is None + + +def test_get_valid_parameter_with_required_attr(): + param = {'type': 'number', 'required': True, 'name': 'foobar'} + result = ParameterValidator.validate_parameter('formdata', 20, param) + assert result is None + + +def test_get_valid_path_parameter(): + param = {'required': True, 'schema': {'type': 'number'}, 'name': 'foobar'} + result = ParameterValidator.validate_parameter('path', 20, param) + assert result is None + + +def test_get_missing_required_parameter(): + param = {'type': 'number', 'required': True, 'name': 'foo'} + result = ParameterValidator.validate_parameter('formdata', None, param) + assert result == "Missing formdata parameter 'foo'" + + +def test_get_x_nullable_parameter(): + param = {'type': 'number', 'required': True, 'name': 'foo', 'x-nullable': True} + result = ParameterValidator.validate_parameter('formdata', 'None', param) + assert result is None + + +def test_get_nullable_parameter(): + param = {'schema': {'type': 'number', 'nullable': True}, + 'required': True, 'name': 'foo'} + result = ParameterValidator.validate_parameter('query', 'null', param) + assert result is None + + +def test_get_explodable_object_parameter(): + param = {'schema': {'type': 'object', 'additionalProperties': True}, + 'required': True, 'name': 'foo', 'style': 'deepObject', 'explode': True} + result = ParameterValidator.validate_parameter('query', {'bar': 1}, param) + assert result is None + + +def test_get_valid_parameter_with_enum_array_header(): + value = 'VALUE1,VALUE2' + param = {'schema': {'type': 'array', 'items': {'type': 'string', 'enum': ['VALUE1', 'VALUE2']}}, + 'name': 'test_header_param'} + result = ParameterValidator.validate_parameter('header', value, param) + assert result is None + + +def test_invalid_type(monkeypatch): + logger = MagicMock() + monkeypatch.setattr('firetail.decorators.validation.logger', logger) + result = ParameterValidator.validate_parameter('formdata', 20, {'type': 'string', 'name': 'foo'}) + expected_result = """20 is not of type 'string' + +Failed validating 'type' in schema: + {'name': 'foo', 'type': 'string'} + +On instance: + 20""" + assert result == expected_result + logger.info.assert_called_once() + + +def test_invalid_type_value_error(monkeypatch): + logger = MagicMock() + monkeypatch.setattr('firetail.decorators.validation.logger', logger) + value = {'test': 1, 'second': 2} + result = ParameterValidator.validate_parameter('formdata', value, {'type': 'boolean', 'name': 'foo'}) + assert result == "Wrong type, expected 'boolean' for formdata parameter 'foo'" + + +def test_enum_error(monkeypatch): + logger = MagicMock() + monkeypatch.setattr('firetail.decorators.validation.logger', logger) + value = 'INVALID' + param = {'schema': {'type': 'string', 'enum': ['valid']}, + 'name': 'test_path_param'} + result = ParameterValidator.validate_parameter('path', value, param) + assert result.startswith("'INVALID' is not one of ['valid']") + + +def test_support_nullable_properties(): + schema = { + "type": "object", + "properties": {"foo": {"type": "string", "x-nullable": True}}, + } + try: + Draft4RequestValidator(schema).validate({"foo": None}) + except ValidationError: + pytest.fail("Shouldn't raise ValidationError") + + +def test_support_nullable_properties_raises_validation_error(): + schema = { + "type": "object", + "properties": {"foo": {"type": "string", "x-nullable": False}}, + } + + with pytest.raises(ValidationError): + Draft4RequestValidator(schema).validate({"foo": None}) + + +def test_support_nullable_properties_not_iterable(): + schema = { + "type": "object", + "properties": {"foo": {"type": "string", "x-nullable": True}}, + } + with pytest.raises(ValidationError): + Draft4RequestValidator(schema).validate(12345) + + +def test_nullable_enum(): + schema = { + "enum": ["foo", 7], + "nullable": True + } + try: + Draft4RequestValidator(schema).validate(None) + except ValidationError: + pytest.fail("Shouldn't raise ValidationError") + + +def test_nullable_enum_error(): + schema = { + "enum": ["foo", 7] + } + with pytest.raises(ValidationError): + Draft4RequestValidator(schema).validate(None) + + +def test_writeonly_value(): + schema = { + "type": "object", + "properties": {"foo": {"type": "string", "writeOnly": True}}, + } + try: + Draft4RequestValidator(schema).validate({"foo": "bar"}) + except ValidationError: + pytest.fail("Shouldn't raise ValidationError") + + +def test_writeonly_value_error(): + schema = { + "type": "object", + "properties": {"foo": {"type": "string", "writeOnly": True}}, + } + with pytest.raises(ValidationError): + Draft4ResponseValidator(schema).validate({"foo": "bar"}) + + +def test_writeonly_required(): + schema = { + "type": "object", + "required": ["foo"], + "properties": {"foo": {"type": "string", "writeOnly": True}}, + } + try: + Draft4RequestValidator(schema).validate({"foo": "bar"}) + except ValidationError: + pytest.fail("Shouldn't raise ValidationError") + + +def test_writeonly_required_error(): + schema = { + "type": "object", + "required": ["foo"], + "properties": {"foo": {"type": "string", "writeOnly": True}}, + } + with pytest.raises(ValidationError): + Draft4RequestValidator(schema).validate({"bar": "baz"}) + + +def test_formdata_extra_parameter_strict(): + """Tests that firetail handles explicitly defined formData parameters well across Swagger 2 + and OpenApi 3. In Swagger 2, any formData parameter should be defined explicitly, while in + OpenAPI 3 this is not allowed. See issues #1020 #1160 #1340 #1343.""" + request = MagicMock(form={'param': 'value', 'extra_param': 'extra_value'}) + + # OAS3 + validator = ParameterValidator([], FlaskApi, strict_validation=True) + errors = validator.validate_formdata_parameter_list(request) + assert not errors + + # Swagger 2 + validator = ParameterValidator([{'in': 'formData', 'name': 'param'}], FlaskApi, + strict_validation=True) + errors = validator.validate_formdata_parameter_list(request) + assert errors diff --git a/tests/fakeapi/__init__.py b/tests/fakeapi/__init__.py index 9918df9..c47fe10 100644 --- a/tests/fakeapi/__init__.py +++ b/tests/fakeapi/__init__.py @@ -1,5 +1,5 @@ -from .example_method_view import ExampleMethodView - - -def get(): - return '' +from .example_method_view import ExampleMethodView + + +def get(): + return '' diff --git a/tests/fakeapi/aiohttp_handlers.py b/tests/fakeapi/aiohttp_handlers.py new file mode 100755 index 0000000..f9c07c0 --- /dev/null +++ b/tests/fakeapi/aiohttp_handlers.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +import datetime +import uuid + +import aiohttp +from aiohttp.web import Request +from aiohttp.web import Response as AioHttpResponse +from firetail.lifecycle import FiretailResponse + + +async def get_bye(name): + return AioHttpResponse(text=f'Goodbye {name}') + + +async def aiohttp_str_response(): + return 'str response' + + +async def aiohttp_non_str_non_json_response(): + return 1234 + + +async def aiohttp_bytes_response(): + return b'bytes response' + + +async def aiohttp_validate_responses(): + return {"validate": True} + + +async def aiohttp_post_greeting(name, **kwargs): + data = {'greeting': f'Hello {name}'} + return data + +async def aiohttp_echo(**kwargs): + return aiohttp.web.json_response(data=kwargs, status=200) + + +async def aiohttp_access_request_context(request_ctx): + assert request_ctx is not None + assert isinstance(request_ctx, aiohttp.web.Request) + return None + + +async def aiohttp_query_parsing_str(query): + return {'query': query} + + +async def aiohttp_query_parsing_array(query): + return {'query': query} + + +async def aiohttp_query_parsing_array_multi(query): + return {'query': query} + + +USERS = [ + {"id": 1, "name": "John Doe"}, + {"id": 2, "name": "Nick Carlson"} +] + + +async def aiohttp_users_get(*args): + return aiohttp.web.json_response(data=USERS, status=200) + + +async def aiohttp_users_post(user): + if "name" not in user: + return FiretailResponse(body={"error": "name is undefined"}, + status_code=400, + content_type='application/json') + user['id'] = len(USERS) + 1 + USERS.append(user) + return aiohttp.web.json_response(data=USERS[-1], status=201) + + +async def aiohttp_token_info(token_info): + return aiohttp.web.json_response(data=token_info) + + +async def aiohttp_all_auth(token_info): + return await aiohttp_token_info(token_info) + + +async def aiohttp_async_auth(token_info): + return await aiohttp_token_info(token_info) + + +async def aiohttp_bearer_auth(token_info): + return await aiohttp_token_info(token_info) + + +async def aiohttp_async_bearer_auth(token_info): + return await aiohttp_token_info(token_info) + +async def aiohttp_async_auth_exception(token_info): + return await aiohttp_token_info(token_info) + +async def get_datetime(): + return FiretailResponse(body={'value': datetime.datetime(2000, 1, 2, 3, 4, 5, 6)}) + + +async def get_date(): + return FiretailResponse(body={'value': datetime.date(2000, 1, 2)}) + + +async def get_uuid(): + return FiretailResponse(body={'value': uuid.UUID(hex='e7ff66d0-3ec2-4c4e-bed0-6e4723c24c51')}) + + +async def aiohttp_multipart_single_file(myfile): + return aiohttp.web.json_response( + data={ + 'fileName': myfile.filename, + 'myfile_content': myfile.file.read().decode('utf8') + }, + ) + + +async def aiohttp_multipart_many_files(myfiles): + return aiohttp.web.json_response( + data={ + 'files_count': len(myfiles), + 'myfiles_content': [ f.file.read().decode('utf8') for f in myfiles ] + }, + ) + + +async def aiohttp_multipart_mixed_single_file(myfile, body): + dir_name = body['dir_name'] + return aiohttp.web.json_response( + data={ + 'dir_name': dir_name, + 'fileName': myfile.filename, + 'myfile_content': myfile.file.read().decode('utf8'), + }, + ) + + +async def aiohttp_multipart_mixed_many_files(myfiles, body): + dir_name = body['dir_name'] + test_count = body['test_count'] + return aiohttp.web.json_response( + data={ + 'files_count': len(myfiles), + 'dir_name': dir_name, + 'test_count': test_count, + 'myfiles_content': [ f.file.read().decode('utf8') for f in myfiles ] + }, + ) + + +async def test_cookie_param(request): + return {"cookie_value": request.cookies["test_cookie"]} diff --git a/tests/fakeapi/aiohttp_handlers_async_def.py b/tests/fakeapi/aiohttp_handlers_async_def.py new file mode 100644 index 0000000..022d2cc --- /dev/null +++ b/tests/fakeapi/aiohttp_handlers_async_def.py @@ -0,0 +1,5 @@ +from firetail.lifecycle import FiretailResponse + + +async def aiohttp_validate_responses(): + return FiretailResponse(body=b'{"validate": true}') diff --git a/tests/fakeapi/auth.py b/tests/fakeapi/auth.py index ee8960e..9cbaa0b 100644 --- a/tests/fakeapi/auth.py +++ b/tests/fakeapi/auth.py @@ -1,27 +1,32 @@ -import asyncio -import json - - -def fake_basic_auth(username, password, required_scopes=None): - if username == password: - return {'uid': username} - return None - - -def fake_json_auth(token, required_scopes=None): - try: - return json.loads(token) - except ValueError: - return None - - -async def async_basic_auth(username, password, required_scopes=None, request=None): - return fake_basic_auth(username, password, required_scopes) - - -async def async_json_auth(token, required_scopes=None, request=None): - return fake_json_auth(token, required_scopes) - - -async def async_scope_validation(required_scopes, token_scopes, request): - return required_scopes == token_scopes +import asyncio +import json + +from firetail.exceptions import OAuthProblem + + +def fake_basic_auth(username, password, required_scopes=None): + if username == password: + return {'uid': username} + return None + + +def fake_json_auth(token, required_scopes=None): + try: + return json.loads(token) + except ValueError: + return None + + +async def async_basic_auth(username, password, required_scopes=None, request=None): + return fake_basic_auth(username, password, required_scopes) + + +async def async_json_auth(token, required_scopes=None, request=None): + return fake_json_auth(token, required_scopes) + + +async def async_scope_validation(required_scopes, token_scopes, request): + return required_scopes == token_scopes + +async def async_auth_exception(token, required_scopes=None, request=None): + raise OAuthProblem diff --git a/tests/fakeapi/example_method_view.py b/tests/fakeapi/example_method_view.py index 6a29f60..65f91da 100644 --- a/tests/fakeapi/example_method_view.py +++ b/tests/fakeapi/example_method_view.py @@ -1,15 +1,15 @@ -from flask.views import MethodView - - -class ExampleMethodView(MethodView): - mycontent="demonstrate return from MethodView class" - def get(self): - return self.mycontent - def search(self): - return self.mycontent - def api_list(self): - return self.mycontent - def post_greeting(self): - return self.mycontent - def post(self): - return self.mycontent +from flask.views import MethodView + + +class ExampleMethodView(MethodView): + mycontent="demonstrate return from MethodView class" + def get(self): + return self.mycontent + def search(self): + return self.mycontent + def api_list(self): + return self.mycontent + def post_greeting(self): + return self.mycontent + def post(self): + return self.mycontent diff --git a/tests/fakeapi/foo_bar.py b/tests/fakeapi/foo_bar.py old mode 100644 new mode 100755 index 708a37f..3b68520 --- a/tests/fakeapi/foo_bar.py +++ b/tests/fakeapi/foo_bar.py @@ -1,5 +1,5 @@ -#!/usr/bin/env python3 - - -def search(): - return '' +#!/usr/bin/env python3 + + +def search(): + return '' diff --git a/tests/fakeapi/hello/__init__.py b/tests/fakeapi/hello/__init__.py index 506dfa6..a6c72e4 100644 --- a/tests/fakeapi/hello/__init__.py +++ b/tests/fakeapi/hello/__init__.py @@ -1,642 +1,633 @@ -#!/usr/bin/env python3 -import datetime -import uuid - -from firetail import NoContent, ProblemException, context, request -from flask import jsonify, redirect, send_file - - -class DummyClass: - @classmethod - def test_classmethod(cls): - return cls.__name__ - - def test_method(self): - return self.__class__.__name__ - - -class_instance = DummyClass() # noqa - - -def get(): - return '' - - -def search(): - return '' - - -def api_list(): - return 'a' - - -def post(): - return '' - - -def post_greeting(name, **kwargs): - data = {'greeting': f'Hello {name}'} - return data - - -def post_greeting3(body, **kwargs): - data = {'greeting': 'Hello {name}'.format(name=body["name"])} - return data - - -def post_greeting_url(name, remainder, **kwargs): - data = {'greeting': f'Hello {name} thanks for {remainder}'} - return data - - -def post_goodday(name): - data = {'greeting': f'Hello {name}'} - headers = {"Location": "/my/uri"} - return data, 201, headers - - -def post_goodday_no_header(): - return {'greeting': 'Hello.'}, 201 - - -def post_goodevening(name): - data = f'Good evening {name}' - headers = {"Location": "/my/uri"} - return data, 201, headers - - -def get_list(name): - data = ['hello', name] - return data - - -def get_bye(name): - return f'Goodbye {name}' - - -def get_flask_response_tuple(): - return jsonify({'foo': 'bar'}), 201 - - -def get_bye_secure(name, user, token_info): - return f'Goodbye {name} (Secure: {user})' - - -def get_bye_secure_from_flask(): - return 'Goodbye {user} (Secure!)'.format(user=context['user']) - - -def get_bye_secure_from_firetail(req_context): - return 'Goodbye {user} (Secure!)'.format(user=req_context['user']) - - -def get_bye_secure_ignoring_context(name): - return f'Goodbye {name} (Secure!)' - - -def get_bye_secure_jwt(name, user, token_info): - return f'Goodbye {name} (Secure: {user})' - - -def with_problem(): - raise ProblemException(type='http://www.example.com/error', - title='Some Error', - detail='Something went wrong somewhere', - status=402, - instance='instance1', - headers={'x-Test-Header': 'In Test'}) - - -def with_problem_txt(): - raise ProblemException(title='Some Error', - detail='Something went wrong somewhere', - status=402, - instance='instance1') - - -def internal_error(): - return 42 / 0 - - -def get_greetings(name): - """ - Used to test custom mimetypes - """ - data = {'greetings': f'Hello {name}'} - return data - - -def multimime(): - return 'Goodbye' - - -def empty(): - return None, 204 - - -def schema(new_stack): - return new_stack - - -def forward(body): - """Return a response with the same payload as in the request body.""" - return body - - -def schema_response_object(valid): - if valid == "invalid_requirements": - return {"docker_version": 1.0} - elif valid == "invalid_type": - return {"image_version": 1.0} - else: - return {"image_version": "1.0"} # valid - - -def schema_response_string(valid): - if valid == "valid": - return "Image version 2.0" - else: - return 2.0 - - -def schema_response_integer(valid): - if valid == "valid": - return 3 - else: - return 3.0 - - -def schema_response_number(valid): - if valid == "valid": - return 4.0 - else: - return "Four" - - -def schema_response_boolean(valid): - if valid == "valid": - return True - else: - return "yes" - - -def schema_response_array(valid): - if valid == "invalid_dict": - return { - {"image_version": "1.0"}: - {"image_version": "2.0"} - } - elif valid == "invalid_string": - return "Not an array." - else: - return [ - {"image_version": "1.0"}, - {"image_version": "2.0"} - ] - - -def schema_query(image_version=None): - return {'image_version': image_version} - - -def schema_list(): - return '' - - -def schema_map(): - return '' - - -def schema_recursive(): - return '' - - -def schema_format(): - return '' - - -def test_parameter_validation(): - return '' - - -def test_required_query_param(): - return '' - - -def test_apikey_query_parameter_validation(): - return '' - - -def test_array_csv_query_param(items): - return items - - -def test_array_pipes_form_param3(items): - return items['items'] - - -def test_array_csv_form_param3(items): - return items['items'] - - -def test_array_pipes_form_param(items): - return items - - -def test_array_csv_form_param(items): - return items - - -def test_array_multi_query_param(items): - return items - - -def test_array_pipes_query_param(items): - return items - - -def test_array_unsupported_query_param(items): - return items - - -def test_no_content_response(): - return NoContent, 204 - - -def test_schema_array(test_array): - return test_array - - -def test_schema_int(test_int): - return test_int - - -def test_get_someint(someint): - return f'{type(someint).__name__} {someint:g}' - - -def test_get_somefloat(somefloat): - return f'{type(somefloat).__name__} {somefloat:g}' - - -def test_default_param(name): - return {"app_name": name} - - -def test_default_object_body(stack): - return {"stack": stack} - - -def test_nested_additional_properties(body): - return body - - -def test_default_integer_body(stack_version): - return stack_version - - -def test_empty_object_body(stack): - return {"stack": stack} - - -def test_falsy_param(falsy): - return falsy - - -def test_formdata_param3(body): - return body["formData"] - - -def test_formdata_param(formData): - return formData - - -def test_formdata_missing_param(): - return '' - - -def test_formdata_file_upload(formData, **kwargs): - filename = formData.filename - contents = formData.read().decode('utf-8', 'replace') - return {filename: contents} - - -def test_formdata_file_upload_missing_param(): - return '' - - -def test_bool_default_param(thruthiness): - return thruthiness - - -def test_bool_array_param(thruthiness=None): - if thruthiness is None: - thruthiness = [] - return all(thruthiness) - - -def test_required_param(simple): - return simple - - -def test_cookie_param(): - return {"cookie_value": request.cookies["test_cookie"]} - - -def test_exploded_deep_object_param(id): - return id - - -def test_nested_exploded_deep_object_param(id): - return id - - -def test_exploded_deep_object_param_additional_properties(id): - return id - - -def test_redirect_endpoint(): - headers = {'Location': 'http://www.google.com/'} - return '', 302, headers - - -def test_redirect_response_endpoint(): - return redirect('http://www.google.com/') - - -def test_204_with_headers(): - headers = {'X-Something': 'test'} - return '', 204, headers - - -def test_nocontent_obj_with_headers(): - headers = {'X-Something': 'test'} - return NoContent, 204, headers - - -def path_parameters_in_get_method(title): - return [title], 200, {} - - -def test_default_missmatch_definition(age): - return 'OK' - - -def test_array_in_path(names): - return names, 200 - - -def test_global_response_definition(): - return ['general', 'list'], 200 - - -def test_nullable_parameters(time_start): - if time_start is None: - return 'it was None' - return time_start - - -def test_nullable_param_post(post_param): - if post_param is None: - return 'it was None' - return post_param - - -def test_nullable_param_post3(body): - if body is None: - return 'it was None' - if body["post_param"] is None: - return 'it was None' - return body["post_param"] - - -def test_nullable_param_put(contents): - if contents is None: - return 'it was None' - return contents - - -def test_nullable_param_put_noargs(dummy=''): - return 'hello' - - -def test_custom_json_response(): - return {'theResult': DummyClass()}, 200 - - -def get_blob_data(): - return b'cool\x00\x08' - - -def get_data_as_binary(): - return get_blob_data(), 200, {'Content-Type': 'application/octet-stream'} - - -def get_data_as_text(post_param): - return '' - - -def get_invalid_response(): - return {"simple": object()} - - -def get_empty_dict(): - return {} - - -def get_custom_problem_response(): - raise ProblemException(403, "You need to pay", "Missing amount", - ext={'amount': 23.0}) - - -def throw_problem_exception(): - raise ProblemException( - title="As Exception", - detail="Something wrong or not!", - ext={'age': 30} - ) - - -def unordered_params_response(first, path_param, second): - return dict(first=int(first), path_param=str(path_param), second=int(second)) - - -def more_than_one_scope_defined(**kwargs): - return "OK" - - -def optional_auth(**kwargs): - key = apikey_info(request.headers.get('X-AUTH')) - if key is None: - return "Unauthenticated" - else: - return "Authenticated" - - -def test_args_kwargs(*args, **kwargs): - return kwargs - - -def test_args_kwargs_post(*args, **kwargs): - return kwargs - - -def test_param_sanitization(query=None, form=None): - result = {} - if query: - result['query'] = query - if form: - result['form'] = form - return result - - -def test_param_sanitization3(query=None, body=None): - result = {} - if query: - result['query'] = query - if body: - result['form'] = body["$form"] - return result - - -def test_body_sanitization(body=None): - return body - - -def test_body_sanitization_additional_properties(body): - return body - - -def test_body_sanitization_additional_properties_defined(body): - return body - - -def test_body_not_allowed_additional_properties(body): - return body - - -def post_wrong_content_type(): - return "NOT OK" - - -def get_unicode_query(price=None): - return {'price': price} - - -def get_unicode_data(): - jsonResponse = {'currency': '\xa3', 'key': 'leena'} - return jsonResponse - - -def get_enum_response(): - try: - from enum import Enum - - class HTTPStatus(Enum): - OK = 200 - except ImportError: - return {}, 200 - else: - return {}, HTTPStatus.OK - - -def get_httpstatus_response(): - try: - from http import HTTPStatus - except ImportError: - return {}, 200 - else: - return {}, HTTPStatus.OK - - -def get_bad_default_response(response_code): - return {}, response_code - - -def get_user(): - return {'user_id': 7, 'name': 'max'} - - -def get_user_with_password(): - return {'user_id': 7, 'name': 'max', 'password': '5678'} - - -def post_user(body): - body['user_id'] = 8 - body.pop('password', None) - return body - - -def post_multipart_form(body): - x = body['x'] - x['name'] += "-reply" - x['age'] += 10 - return x - - -def apikey_info(apikey, required_scopes=None): - if apikey == 'mykey': - return {'sub': 'admin'} - return None - - -def jwt_info(token): - if token == '100': - return {'sub': '100'} - return None - - -def get_add_operation_on_http_methods_only(): - return "" - - -def put_add_operation_on_http_methods_only(): - return "" - - -def post_add_operation_on_http_methods_only(): - return "" - - -def delete_add_operation_on_http_methods_only(): - return "" - - -def options_add_operation_on_http_methods_only(): - return "" - - -def head_add_operation_on_http_methods_only(): - return "" - - -def patch_add_operation_on_http_methods_only(): - return "" - - -def trace_add_operation_on_http_methods_only(): - return "" - - -def get_datetime(): - return {'value': datetime.datetime(2000, 1, 2, 3, 4, 5, 6)} - - -def get_date(): - return {'value': datetime.date(2000, 1, 2)} - - -def get_uuid(): - return {'value': uuid.UUID(hex='e7ff66d0-3ec2-4c4e-bed0-6e4723c24c51')} - - -def test_optional_headers(): - return {}, 200 - - -def nullable_default(test): - return - - -def get_streaming_response(): - return send_file(__file__) +#!/usr/bin/env python3 +import datetime +import uuid + +from firetail import NoContent, ProblemException, context, request +from firetail.exceptions import OAuthProblem +from flask import jsonify, redirect, send_file + + +class DummyClass: + @classmethod + def test_classmethod(cls): + return cls.__name__ + + def test_method(self): + return self.__class__.__name__ + +class_instance = DummyClass() # noqa + + +def get(): + return '' + + +def search(): + return '' + + +def api_list(): + return 'a' + + +def post(): + return '' + + +def post_greeting(name, **kwargs): + data = {'greeting': f'Hello {name}'} + return data + +def post_greeting3(body, **kwargs): + data = {'greeting': 'Hello {name}'.format(name=body["name"])} + return data + +def post_greeting_url(name, remainder, **kwargs): + data = {'greeting': f'Hello {name} thanks for {remainder}'} + return data + +def post_goodday(name): + data = {'greeting': f'Hello {name}'} + headers = {"Location": "/my/uri"} + return data, 201, headers + + +def post_goodday_no_header(): + return {'greeting': 'Hello.'}, 201 + + +def post_goodevening(name): + data = f'Good evening {name}' + headers = {"Location": "/my/uri"} + return data, 201, headers + + +def get_list(name): + data = ['hello', name] + return data + + +def get_bye(name): + return f'Goodbye {name}' + + +def get_flask_response_tuple(): + return jsonify({'foo': 'bar'}), 201 + + +def get_bye_secure(name, user, token_info): + return f'Goodbye {name} (Secure: {user})' + + +def get_bye_secure_from_flask(): + return 'Goodbye {user} (Secure!)'.format(user=context['user']) + + +def get_bye_secure_from_firetail(req_context): + return 'Goodbye {user} (Secure!)'.format(user=req_context['user']) + + +def get_bye_secure_ignoring_context(name): + return f'Goodbye {name} (Secure!)' + +def get_bye_secure_jwt(name, user, token_info): + return f'Goodbye {name} (Secure: {user})' + +def with_problem(): + raise ProblemException(type='http://www.example.com/error', + title='Some Error', + detail='Something went wrong somewhere', + status=418, + instance='instance1', + headers={'x-Test-Header': 'In Test'}) + + +def with_problem_txt(): + raise ProblemException(title='Some Error', + detail='Something went wrong somewhere', + status=418, + instance='instance1') + + +def internal_error(): + return 42 / 0 + + +def get_greetings(name): + """ + Used to test custom mimetypes + """ + data = {'greetings': f'Hello {name}'} + return data + + +def multimime(): + return 'Goodbye' + + +def empty(): + return None, 204 + + +def schema(new_stack): + return new_stack + + +def forward(body): + """Return a response with the same payload as in the request body.""" + return body + + +def schema_response_object(valid): + if valid == "invalid_requirements": + return {"docker_version": 1.0} + elif valid == "invalid_type": + return {"image_version": 1.0} + else: + return {"image_version": "1.0"} # valid + + +def schema_response_string(valid): + if valid == "valid": + return "Image version 2.0" + else: + return 2.0 + + +def schema_response_integer(valid): + if valid == "valid": + return 3 + else: + return 3.0 + + +def schema_response_number(valid): + if valid == "valid": + return 4.0 + else: + return "Four" + + +def schema_response_boolean(valid): + if valid == "valid": + return True + else: + return "yes" + + +def schema_response_array(valid): + if valid == "invalid_dict": + return { + {"image_version": "1.0"}: + {"image_version": "2.0"} + } + elif valid == "invalid_string": + return "Not an array." + else: + return [ + {"image_version": "1.0"}, + {"image_version": "2.0"} + ] + + +def schema_query(image_version=None): + return {'image_version': image_version} + + +def schema_list(): + return '' + + +def schema_map(): + return '' + + +def schema_recursive(): + return '' + + +def schema_format(): + return '' + + +def test_parameter_validation(): + return '' + + +def test_required_query_param(): + return '' + + +def test_apikey_query_parameter_validation(): + return '' + + +def test_array_csv_query_param(items): + return items + + +def test_array_pipes_form_param3(items): + return items['items'] + + +def test_array_csv_form_param3(items): + return items['items'] + + +def test_array_pipes_form_param(items): + return items + + +def test_array_csv_form_param(items): + return items + + +def test_array_multi_query_param(items): + return items + + +def test_array_pipes_query_param(items): + return items + + +def test_array_unsupported_query_param(items): + return items + + +def test_no_content_response(): + return NoContent, 204 + + +def test_schema_array(test_array): + return test_array + + +def test_schema_int(test_int): + return test_int + + +def test_get_someint(someint): + return f'{type(someint).__name__} {someint:g}' + + +def test_get_somefloat(somefloat): + return f'{type(somefloat).__name__} {somefloat:g}' + + +def test_default_param(name): + return {"app_name": name} + + +def test_default_object_body(stack): + return {"stack": stack} + +def test_nested_additional_properties(body): + return body + +def test_default_integer_body(stack_version): + return stack_version + + +def test_empty_object_body(stack): + return {"stack": stack} + + +def test_falsy_param(falsy): + return falsy + +def test_formdata_param3(body): + return body["formData"] + + +def test_formdata_param(formData): + return formData + + +def test_formdata_missing_param(): + return '' + + +def test_formdata_file_upload(formData, **kwargs): + filename = formData.filename + contents = formData.read().decode('utf-8', 'replace') + return {filename: contents} + + +def test_formdata_file_upload_missing_param(): + return '' + + +def test_bool_default_param(thruthiness): + return thruthiness + + +def test_bool_array_param(thruthiness=None): + if thruthiness is None: + thruthiness = [] + return all(thruthiness) + + +def test_required_param(simple): + return simple + + +def test_cookie_param(): + return {"cookie_value": request.cookies["test_cookie"]} + + +def test_exploded_deep_object_param(id): + return id + + +def test_nested_exploded_deep_object_param(id): + return id + + +def test_exploded_deep_object_param_additional_properties(id): + return id + + +def test_redirect_endpoint(): + headers = {'Location': 'http://www.google.com/'} + return '', 302, headers + + +def test_redirect_response_endpoint(): + return redirect('http://www.google.com/') + + +def test_204_with_headers(): + headers = {'X-Something': 'test'} + return '', 204, headers + + +def test_nocontent_obj_with_headers(): + headers = {'X-Something': 'test'} + return NoContent, 204, headers + + +def path_parameters_in_get_method(title): + return [title], 200, {} + + +def test_default_missmatch_definition(age): + return 'OK' + + +def test_array_in_path(names): + return names, 200 + + +def test_global_response_definition(): + return ['general', 'list'], 200 + + +def test_nullable_parameters(time_start): + if time_start is None: + return 'it was None' + return time_start + + +def test_nullable_param_post(post_param): + if post_param is None: + return 'it was None' + return post_param + + +def test_nullable_param_post3(body): + if body is None: + return 'it was None' + if body["post_param"] is None: + return 'it was None' + return body["post_param"] + + +def test_nullable_param_put(contents): + if contents is None: + return 'it was None' + return contents + +def test_nullable_param_put_noargs(dummy=''): + return 'hello' + + +def test_custom_json_response(): + return {'theResult': DummyClass()}, 200 + + +def get_blob_data(): + return b'cool\x00\x08' + + +def get_data_as_binary(): + return get_blob_data(), 200, {'Content-Type': 'application/octet-stream'} + + +def get_data_as_text(post_param): + return '' + + +def get_invalid_response(): + return {"simple": object()} + + +def get_empty_dict(): + return {} + + +def get_custom_problem_response(): + raise ProblemException(403, "You need to pay", "Missing amount", + ext={'amount': 23.0}) + + +def throw_problem_exception(): + raise ProblemException( + title="As Exception", + detail="Something wrong or not!", + ext={'age': 30} + ) + + +def unordered_params_response(first, path_param, second): + return dict(first=int(first), path_param=str(path_param), second=int(second)) + + +def more_than_one_scope_defined(**kwargs): + return "OK" + + +def optional_auth(**kwargs): + key = apikey_info(request.headers.get('X-AUTH')) + if key is None: + return "Unauthenticated" + else: + return "Authenticated" + + +def auth_exception(): + return 'foo' + +def test_args_kwargs(*args, **kwargs): + return kwargs + +def test_args_kwargs_post(*args, **kwargs): + return kwargs + + +def test_param_sanitization(query=None, form=None): + result = {} + if query: + result['query'] = query + if form: + result['form'] = form + return result + + +def test_param_sanitization3(query=None, body=None): + result = {} + if query: + result['query'] = query + if body: + result['form'] = body["$form"] + return result + + +def test_body_sanitization(body=None): + return body + +def test_body_sanitization_additional_properties(body): + return body + +def test_body_sanitization_additional_properties_defined(body): + return body + +def test_body_not_allowed_additional_properties(body): + return body + +def post_wrong_content_type(): + return "NOT OK" + + +def get_unicode_query(price=None): + return {'price': price} + + +def get_unicode_data(): + jsonResponse = {'currency': '\xa3', 'key': 'leena'} + return jsonResponse + + +def get_enum_response(): + try: + from enum import Enum + class HTTPStatus(Enum): + OK = 200 + except ImportError: + return {}, 200 + else: + return {}, HTTPStatus.OK + + +def get_httpstatus_response(): + try: + from http import HTTPStatus + except ImportError: + return {}, 200 + else: + return {}, HTTPStatus.OK + + +def get_bad_default_response(response_code): + return {}, response_code + + +def get_user(): + return {'user_id': 7, 'name': 'max'} + + +def get_user_with_password(): + return {'user_id': 7, 'name': 'max', 'password': '5678'} + + +def post_user(body): + body['user_id'] = 8 + body.pop('password', None) + return body + +def post_multipart_form(body): + x = body['x'] + x['name'] += "-reply" + x['age'] += 10 + return x + + +def apikey_info(apikey, required_scopes=None): + if apikey == 'mykey': + return {'sub': 'admin'} + return None + + +def jwt_info(token): + if token == '100': + return {'sub': '100'} + return None + + +def apikey_exception(token): + raise OAuthProblem() + + +def get_add_operation_on_http_methods_only(): + return "" + + +def put_add_operation_on_http_methods_only(): + return "" + + +def post_add_operation_on_http_methods_only(): + return "" + + +def delete_add_operation_on_http_methods_only(): + return "" + + +def options_add_operation_on_http_methods_only(): + return "" + + +def head_add_operation_on_http_methods_only(): + return "" + + +def patch_add_operation_on_http_methods_only(): + return "" + + +def trace_add_operation_on_http_methods_only(): + return "" + + +def get_datetime(): + return {'value': datetime.datetime(2000, 1, 2, 3, 4, 5, 6)} + + +def get_date(): + return {'value': datetime.date(2000, 1, 2)} + + +def get_uuid(): + return {'value': uuid.UUID(hex='e7ff66d0-3ec2-4c4e-bed0-6e4723c24c51')} + + +def test_optional_headers(): + return {}, 200 + + +def nullable_default(test): + return + + +def get_streaming_response(): + return send_file(__file__) diff --git a/tests/fakeapi/hello/world.py b/tests/fakeapi/hello/world.py index 90dfe9d..41345e1 100644 --- a/tests/fakeapi/hello/world.py +++ b/tests/fakeapi/hello/world.py @@ -1,5 +1,5 @@ -def search(id): - return '' - -def get(id): - return '' +def search(id): + return '' + +def get(id): + return '' diff --git a/tests/fakeapi/module_with_error.py b/tests/fakeapi/module_with_error.py index 4cd7510..b0cb0bc 100644 --- a/tests/fakeapi/module_with_error.py +++ b/tests/fakeapi/module_with_error.py @@ -1,6 +1,6 @@ -# This is a test file, please do not delete. -# It is used by the test: -# - `test_operation.py:test_invalid_operation_does_stop_application_to_setup` -# - `test_api.py:test_invalid_operation_does_stop_application_to_setup` -# - `test_api.py:test_invalid_operation_does_not_stop_application_in_debug_mode` -from foo.bar import foobar # noqa +# This is a test file, please do not delete. +# It is used by the test: +# - `test_operation.py:test_invalid_operation_does_stop_application_to_setup` +# - `test_api.py:test_invalid_operation_does_stop_application_to_setup` +# - `test_api.py:test_invalid_operation_does_not_stop_application_in_debug_mode` +from foo.bar import foobar # noqa diff --git a/tests/fakeapi/module_with_exception.py b/tests/fakeapi/module_with_exception.py index 0e96de8..e8b6427 100644 --- a/tests/fakeapi/module_with_exception.py +++ b/tests/fakeapi/module_with_exception.py @@ -1,6 +1,6 @@ -# This is a test file, please do not delete. -# It is used by the test: -# - `test_operation.py:test_invalid_operation_does_stop_application_to_setup` -# - `test_api.py:test_invalid_operation_does_stop_application_to_setup` -# - `test_api.py:test_invalid_operation_does_not_stop_application_in_debug_mode` -raise ValueError('Forced exception!') +# This is a test file, please do not delete. +# It is used by the test: +# - `test_operation.py:test_invalid_operation_does_stop_application_to_setup` +# - `test_api.py:test_invalid_operation_does_stop_application_to_setup` +# - `test_api.py:test_invalid_operation_does_not_stop_application_in_debug_mode` +raise ValueError('Forced exception!') diff --git a/tests/fakeapi/snake_case.py b/tests/fakeapi/snake_case.py old mode 100644 new mode 100755 index 05a3403..c5a9551 --- a/tests/fakeapi/snake_case.py +++ b/tests/fakeapi/snake_case.py @@ -1,44 +1,44 @@ -#!/usr/bin/env python3 -def get_path_snake(some_id): - data = {'SomeId': some_id} - return data - - -def get_path_shadow(id_): - data = {'id': id_} - return data - - -def get_query_snake(some_id): - data = {'someId': some_id} - return data - - -def get_query_shadow(list_): - data = {'list': list_} - return data - - -def get_camelcase(truthiness, order_by=None): - data = {'truthiness': truthiness, 'order_by': order_by} - return data - - -def post_path_snake(some_id, some_other_id): - data = {'SomeId': some_id, 'SomeOtherId': some_other_id} - return data - - -def post_path_shadow(id_, round_): - data = {'id': id_, 'reduce': round_} - return data - - -def post_query_snake(some_id, some_other_id): - data = {'someId': some_id, 'someOtherId': some_other_id} - return data - - -def post_query_shadow(id_, class_, next_): - data = {'id': id_, 'class': class_, 'next': next_} - return data +#!/usr/bin/env python3 +def get_path_snake(some_id): + data = {'SomeId': some_id} + return data + + +def get_path_shadow(id_): + data = {'id': id_} + return data + + +def get_query_snake(some_id): + data = {'someId': some_id} + return data + + +def get_query_shadow(list_): + data = {'list': list_} + return data + + +def get_camelcase(truthiness, order_by=None): + data = {'truthiness': truthiness, 'order_by': order_by} + return data + + +def post_path_snake(some_id, some_other_id): + data = {'SomeId': some_id, 'SomeOtherId': some_other_id} + return data + + +def post_path_shadow(id_, round_): + data = {'id': id_, 'reduce': round_} + return data + + +def post_query_snake(some_id, some_other_id): + data = {'someId': some_id, 'someOtherId': some_other_id} + return data + + +def post_query_shadow(id_, class_, next_): + data = {'id': id_, 'class': class_, 'next': next_} + return data diff --git a/tests/fixtures/aiohttp/datetime_support.yaml b/tests/fixtures/aiohttp/datetime_support.yaml new file mode 100644 index 0000000..2ee8f74 --- /dev/null +++ b/tests/fixtures/aiohttp/datetime_support.yaml @@ -0,0 +1,60 @@ +openapi: "3.0.1" + +info: + title: "{{title}}" + version: "1.0" +servers: + - url: http://localhost:8080/v1.0 + +paths: + /datetime: + get: + summary: Generate data with date time + operationId: fakeapi.aiohttp_handlers.get_datetime + responses: + 200: + description: date time example + content: + application/json: + schema: + type: object + properties: + value: + type: string + format: date-time + example: + value: 2000-01-23T04:56:07.000008+00:00 + /date: + get: + summary: Generate data with date + operationId: fakeapi.aiohttp_handlers.get_date + responses: + 200: + description: date example + content: + application/json: + schema: + type: object + properties: + value: + type: string + format: date + example: + value: 2000-01-23 + /uuid: + get: + summary: Generate data with uuid + operationId: fakeapi.aiohttp_handlers.get_uuid + responses: + 200: + description: uuid handler + content: + application/json: + schema: + type: object + properties: + value: + type: string + format: uuid + example: + value: 'a7b8869c-5f24-4ce0-a5d1-3e44c3663aa9' diff --git a/tests/fixtures/aiohttp/openapi_empty_base_path.yaml b/tests/fixtures/aiohttp/openapi_empty_base_path.yaml new file mode 100644 index 0000000..505871c --- /dev/null +++ b/tests/fixtures/aiohttp/openapi_empty_base_path.yaml @@ -0,0 +1,28 @@ +openapi: 3.0.0 +servers: + - url: / +info: + title: '{{title}}' + version: '1.0' +paths: + '/bye/{name}': + get: + summary: Generate goodbye + description: Generates a goodbye message. + operationId: fakeapi.aiohttp_handlers.get_bye + responses: + '200': + description: goodbye response + content: + text/plain: + schema: + type: string + default: + description: unexpected error + parameters: + - name: name + in: path + description: Name of the person to say bye. + required: true + schema: + type: string diff --git a/tests/fixtures/aiohttp/openapi_multipart.yaml b/tests/fixtures/aiohttp/openapi_multipart.yaml new file mode 100644 index 0000000..f4374b6 --- /dev/null +++ b/tests/fixtures/aiohttp/openapi_multipart.yaml @@ -0,0 +1,132 @@ +--- +openapi: 3.0.0 +servers: + - url: /v1.0 +info: + title: "{{title}}" + version: "1.0" +paths: + "/upload_file": + post: + summary: Uploads single file + description: Handles multipart file upload. + operationId: fakeapi.aiohttp_handlers.aiohttp_multipart_single_file + responses: + "200": + description: OK response + content: + 'application/json': + schema: + type: object + properties: + fileName: + type: string + default: + description: unexpected error + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + myfile: + type: string + format: binary + "/upload_files": + post: + summary: Uploads many files + description: Handles multipart file upload. + operationId: fakeapi.aiohttp_handlers.aiohttp_multipart_many_files + responses: + "200": + description: OK response + content: + 'application/json': + schema: + type: object + properties: + files_count: + type: number + default: + description: unexpected error + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + myfiles: + type: array + items: + type: string + format: binary + "/mixed_single_file": + post: + summary: Reads multipart data + description: Handles multipart data reading + operationId: fakeapi.aiohttp_handlers.aiohttp_multipart_mixed_single_file + responses: + "200": + description: OK response + content: + 'application/json': + schema: + type: object + properties: + dir_name: + type: string + fileName: + type: string + default: + description: unexpected error + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + dir_name: + type: string + myfile: + type: string + format: binary + "/mixed_many_files": + post: + summary: Reads multipart data + description: Handles multipart data reading + operationId: fakeapi.aiohttp_handlers.aiohttp_multipart_mixed_many_files + responses: + "200": + description: OK response + content: + 'application/json': + schema: + type: object + properties: + dir_name: + type: string + test_count: + type: number + files_count: + type: number + default: + description: unexpected error + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + dir_name: + type: string + test_count: + type: number + myfiles: + type: array + items: + type: string + format: binary diff --git a/tests/fixtures/aiohttp/openapi_secure.yaml b/tests/fixtures/aiohttp/openapi_secure.yaml new file mode 100644 index 0000000..5a87812 --- /dev/null +++ b/tests/fixtures/aiohttp/openapi_secure.yaml @@ -0,0 +1,114 @@ +openapi: 3.0.0 +servers: + - url: /v1.0 +info: + title: '{{title}}' + version: '1.0' +paths: + '/all_auth': + get: + summary: Test basic and oauth auth + operationId: fakeapi.aiohttp_handlers.aiohttp_all_auth + security: + - oauth: + - myscope + - basic: [] + - api_key: [] + responses: + '200': + $ref: "#/components/responses/Success" + '/async_auth': + get: + summary: Test async auth + operationId: fakeapi.aiohttp_handlers.aiohttp_async_auth + security: + - async_oauth: + - myscope + - async_basic: [] + - async_api_key: [] + responses: + '200': + $ref: "#/components/responses/Success" + '/bearer_auth': + get: + summary: Test api key auth + operationId: fakeapi.aiohttp_handlers.aiohttp_bearer_auth + security: + - bearer: [] + responses: + '200': + $ref: "#/components/responses/Success" + '/async_bearer_auth': + get: + summary: Test api key auth + operationId: fakeapi.aiohttp_handlers.aiohttp_async_bearer_auth + security: + - async_bearer: [] + responses: + '200': + $ref: "#/components/responses/Success" + '/fail_auth': + get: + summary: Authentication will always fail + operationId: fakeapi.aiohttp_handlers.aiohttp_async_auth_exception + responses: + '200': + $ref: "#/components/responses/Success" + security: + - api_key_fail: [] +components: + responses: + Success: + description: "Operation succeed" + content: + application/json: + schema: + type: object + + securitySchemes: + oauth: + type: oauth2 + x-tokenInfoUrl: 'https://oauth.example/token_info' + flows: + password: + tokenUrl: 'https://oauth.example/token' + scopes: + myscope: can do stuff + basic: + type: http + scheme: basic + x-basicInfoFunc: fakeapi.auth.fake_basic_auth + api_key: + type: apiKey + in: header + name: X-API-Key + x-apikeyInfoFunc: fakeapi.auth.fake_json_auth + bearer: + type: http + scheme: bearer + x-bearerInfoFunc: fakeapi.auth.fake_json_auth + + async_oauth: + type: oauth2 + flows: {} + x-tokenInfoFunc: fakeapi.auth.async_json_auth + x-scopeValidateFunc: fakeapi.auth.async_scope_validation + async_basic: + type: http + scheme: basic + x-basicInfoFunc: fakeapi.auth.async_basic_auth + async_api_key: + type: apiKey + in: cookie + name: X-API-Key + x-apikeyInfoFunc: fakeapi.auth.async_json_auth + async_bearer: + type: http + scheme: bearer + x-bearerInfoFunc: fakeapi.auth.async_json_auth + + api_key_fail: + type: apiKey + in: header + name: X-API-Key-2 + x-apikeyInfoFunc: fakeapi.auth.async_auth_exception diff --git a/tests/fixtures/aiohttp/openapi_simple.yaml b/tests/fixtures/aiohttp/openapi_simple.yaml new file mode 100644 index 0000000..215aeb6 --- /dev/null +++ b/tests/fixtures/aiohttp/openapi_simple.yaml @@ -0,0 +1,35 @@ +openapi: 3.0.0 +servers: + - url: /v1.0 +info: + title: '{{title}}' + version: '1.0' +paths: + '/pythonic/{id}': + get: + description: test overloading pythonic snake-case and builtins + operationId: fakeapi.aiohttp_handlers.aiohttp_echo + parameters: + - name: id + description: id field + in: path + required: true + schema: + type: integer + responses: + '200': + description: ok + security: [] + /test-cookie-param: + get: + summary: Test cookie parameter support. + operationId: fakeapi.aiohttp_handlers.test_cookie_param + parameters: + - name: test_cookie + in: cookie + required: true + schema: + type: string + responses: + '200': + description: OK diff --git a/tests/fixtures/aiohttp/swagger_empty_base_path.yaml b/tests/fixtures/aiohttp/swagger_empty_base_path.yaml new file mode 100644 index 0000000..da900ee --- /dev/null +++ b/tests/fixtures/aiohttp/swagger_empty_base_path.yaml @@ -0,0 +1,29 @@ +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: / + +paths: + /bye/{name}: + get: + summary: Generate goodbye + description: Generates a goodbye message. + operationId: fakeapi.aiohttp_handlers.get_bye + produces: + - text/plain + responses: + '200': + description: goodbye response + schema: + type: string + default: + description: "unexpected error" + parameters: + - name: name + in: path + description: Name of the person to say bye. + required: true + type: string diff --git a/tests/fixtures/aiohttp/swagger_secure.yaml b/tests/fixtures/aiohttp/swagger_secure.yaml new file mode 100644 index 0000000..b896cdd --- /dev/null +++ b/tests/fixtures/aiohttp/swagger_secure.yaml @@ -0,0 +1,54 @@ +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /v1.0 + +securityDefinitions: + oauth: + type: oauth2 + flow: password + tokenUrl: https://oauth.example/token + x-tokenInfoUrl: https://oauth.example/token_info + scopes: + myscope: can do stuff + basic: + type: basic + x-basicInfoFunc: fakeapi.auth.fake_basic_auth + api_key: + type: apiKey + in: header + name: X-API-Key + x-apikeyInfoFunc: fakeapi.auth.fake_json_auth + api_key_fail: + type: apiKey + in: header + name: X-API-Key-2 + x-apikeyInfoFunc: fakeapi.auth.async_auth_exception + +security: + - oauth: + - myscope + - basic: [] + - api_key: [] +paths: + /all_auth: + get: + summary: Test different authentication + operationId: fakeapi.aiohttp_handlers.aiohttp_token_info + responses: + 200: + description: greeting response + schema: + type: object + /fail_auth: + get: + summary: Authentication will always fail + operationId: fakeapi.aiohttp_handlers.aiohttp_async_auth_exception + responses: + 200: + description: response + security: + - api_key_fail: [] diff --git a/tests/fixtures/aiohttp/swagger_simple.yaml b/tests/fixtures/aiohttp/swagger_simple.yaml new file mode 100644 index 0000000..4a471c7 --- /dev/null +++ b/tests/fixtures/aiohttp/swagger_simple.yaml @@ -0,0 +1,203 @@ +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /v1.0 + +paths: + /bye/{name}: + get: + summary: Generate goodbye + description: Generates a goodbye message. + operationId: fakeapi.aiohttp_handlers.get_bye + produces: + - text/plain + responses: + '200': + description: goodbye response + schema: + type: string + default: + description: "unexpected error" + parameters: + - name: name + in: path + description: Name of the person to say bye. + required: true + type: string + + /aiohttp_str_response: + get: + summary: Return a str response + description: Test returning a str response + operationId: fakeapi.aiohttp_handlers.aiohttp_str_response + produces: + - text/plain + responses: + 200: + description: json response + schema: + type: string + + /aiohttp_non_str_non_json_response: + get: + summary: Return a non str and non json response + description: Test returning a non str and non json response + operationId: fakeapi.aiohttp_handlers.aiohttp_non_str_non_json_response + produces: + - text/plain + responses: + 200: + description: non str non json response + + /aiohttp_bytes_response: + get: + summary: Return a bytes response + description: Test returning a bytes response + operationId: fakeapi.aiohttp_handlers.aiohttp_bytes_response + produces: + - text/plain + responses: + 200: + description: bytes response + + /aiohttp_validate_responses: + get: + summary: Return a bytes response + description: Test returning a bytes response + operationId: fakeapi.aiohttp_handlers.aiohttp_validate_responses + produces: + - application/json + responses: + 200: + description: json response + schema: + type: object + + /aiohttp_access_request_context: + post: + summary: Test request context access + description: Test request context access in handlers. + operationId: fakeapi.aiohttp_handlers.aiohttp_access_request_context + responses: + 204: + description: success no content. + + /users/: + get: + summary: Test get users + description: Get test users list + operationId: fakeapi.aiohttp_handlers.aiohttp_users_get + produces: + - application/json + responses: + 200: + description: Return users + schema: + type: array + items: + $ref: '#/definitions/User' + + post: + summary: Create a new user + description: Add new user to a list of users + operationId: fakeapi.aiohttp_handlers.aiohttp_users_post + consumes: + - application/json + parameters: + - in: body + name: user + description: The user to create + schema: + $ref: '#/definitions/User' + responses: + 201: + description: json response + schema: + $ref: '#/definitions/User' + + /aiohttp_query_parsing_str: + get: + summary: Test proper parsing of query parameters + description: Tests proper parsing + operationId: fakeapi.aiohttp_handlers.aiohttp_query_parsing_str + parameters: + - in: query + name: query + description: Simple query param + type: string + required: true + responses: + 200: + description: Query parsing result + schema: + $ref: '#/definitions/SimpleQuery' + + /aiohttp_query_parsing_array: + get: + summary: Test proper parsing of query parameters + description: Tests proper parsing + operationId: fakeapi.aiohttp_handlers.aiohttp_query_parsing_array + parameters: + - in: query + name: query + description: Array like query param + type: array + items: + type: string + required: true + responses: + 200: + description: Query parsing result + schema: + $ref: '#/definitions/MultiQuery' + + /aiohttp_query_parsing_array_multi: + get: + summary: Test proper parsing of query parameters + description: Tests proper parsing + operationId: fakeapi.aiohttp_handlers.aiohttp_query_parsing_array_multi + parameters: + - in: query + name: query + description: Array like query param + type: array + items: + type: string + collectionFormat: multi + required: true + responses: + 200: + description: Query parsing result + schema: + $ref: '#/definitions/MultiQuery' + + +definitions: + SimpleQuery: + type: object + required: + - query + properties: + query: + type: string + MultiQuery: + type: object + required: + - query + properties: + query: + type: array + items: + type: string + User: + type: object + required: + - name + properties: + id: + type: number + name: + type: string diff --git a/tests/fixtures/aiohttp/swagger_simple_async_def.yaml b/tests/fixtures/aiohttp/swagger_simple_async_def.yaml new file mode 100644 index 0000000..74a89b3 --- /dev/null +++ b/tests/fixtures/aiohttp/swagger_simple_async_def.yaml @@ -0,0 +1,21 @@ +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /v1.0 + +paths: + /aiohttp_validate_responses: + get: + summary: Return a bytes response + description: Test returning a bytes response + operationId: fakeapi.aiohttp_handlers_async_def.aiohttp_validate_responses + produces: + - application/json + responses: + 200: + description: json response + schema: + type: object diff --git a/tests/fixtures/bad_operations/openapi.yaml b/tests/fixtures/bad_operations/openapi.yaml index 89e79c8..ef7613c 100644 --- a/tests/fixtures/bad_operations/openapi.yaml +++ b/tests/fixtures/bad_operations/openapi.yaml @@ -1,34 +1,34 @@ -openapi: 3.0.0 -info: - title: '{{title}}' - version: '1.0' -paths: - /welcome: - get: - operationId: no.module.or.function - responses: - '200': - description: greeting response - content: - '*/*': - schema: - type: object - put: - responses: - '200': - description: greeting response - content: - '*/*': - schema: - type: object - post: - operationId: fakeapi.module_with_error.something - responses: - '200': - description: greeting response - content: - '*/*': - schema: - type: object -servers: - - url: /v1.0 +openapi: 3.0.0 +info: + title: '{{title}}' + version: '1.0' +paths: + /welcome: + get: + operationId: no.module.or.function + responses: + '200': + description: greeting response + content: + '*/*': + schema: + type: object + put: + responses: + '200': + description: greeting response + content: + '*/*': + schema: + type: object + post: + operationId: fakeapi.module_with_error.something + responses: + '200': + description: greeting response + content: + '*/*': + schema: + type: object +servers: + - url: /v1.0 diff --git a/tests/fixtures/bad_operations/swagger.yaml b/tests/fixtures/bad_operations/swagger.yaml index 304002d..9bfca52 100644 --- a/tests/fixtures/bad_operations/swagger.yaml +++ b/tests/fixtures/bad_operations/swagger.yaml @@ -1,31 +1,31 @@ -swagger: "2.0" - -info: - title: "{{title}}" - version: "1.0" - -basePath: /v1.0 - -paths: - /welcome: - get: - operationId: no.module.or.function - responses: - 200: - description: greeting response - schema: - type: object - put: - # operationId: XXX completely missing - responses: - 200: - description: greeting response - schema: - type: object - post: - operationId: fakeapi.module_with_error.something - responses: - 200: - description: greeting response - schema: - type: object +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /v1.0 + +paths: + /welcome: + get: + operationId: no.module.or.function + responses: + 200: + description: greeting response + schema: + type: object + put: + # operationId: XXX completely missing + responses: + 200: + description: greeting response + schema: + type: object + post: + operationId: fakeapi.module_with_error.something + responses: + 200: + description: greeting response + schema: + type: object diff --git a/tests/fixtures/bad_specs/openapi.yaml b/tests/fixtures/bad_specs/openapi.yaml index 79df472..b3cdbc6 100644 --- a/tests/fixtures/bad_specs/openapi.yaml +++ b/tests/fixtures/bad_specs/openapi.yaml @@ -1,23 +1,23 @@ -openapi: 3.0.0 -info: - title: '{{title}}' - version: '1.0' -paths: - /welcome: - get: - operationId: fakeapi.foo_bar.search - parameters: - - name: foo - in: query - schema: - type: integer - default: somestring - responses: - '200': - description: search - content: - '*/*': - schema: - type: object -servers: - - url: /v1.0 +openapi: 3.0.0 +info: + title: '{{title}}' + version: '1.0' +paths: + /welcome: + get: + operationId: fakeapi.foo_bar.search + parameters: + - name: foo + in: query + schema: + type: integer + default: somestring + responses: + '200': + description: search + content: + '*/*': + schema: + type: object +servers: + - url: /v1.0 diff --git a/tests/fixtures/bad_specs/swagger.yaml b/tests/fixtures/bad_specs/swagger.yaml index 54ee2ac..dff8c30 100644 --- a/tests/fixtures/bad_specs/swagger.yaml +++ b/tests/fixtures/bad_specs/swagger.yaml @@ -1,23 +1,23 @@ -swagger: "2.0" - -info: - title: "{{title}}" - version: "1.0" - -basePath: /v1.0 - -paths: - /welcome: - get: - operationId: fakeapi.foo_bar.search - parameters: - # The default below validates, but is obviously the wrong type - - name: foo - in: query - type: integer - default: somestring - responses: - 200: - description: search - schema: - type: object +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /v1.0 + +paths: + /welcome: + get: + operationId: fakeapi.foo_bar.search + parameters: + # The default below validates, but is obviously the wrong type + - name: foo + in: query + type: integer + default: somestring + responses: + 200: + description: search + schema: + type: object diff --git a/tests/fixtures/datetime_support/openapi.yaml b/tests/fixtures/datetime_support/openapi.yaml index 6737bb4..44d3a76 100644 --- a/tests/fixtures/datetime_support/openapi.yaml +++ b/tests/fixtures/datetime_support/openapi.yaml @@ -1,60 +1,60 @@ -openapi: "3.0.1" - -info: - title: "{{title}}" - version: "1.0" -servers: - - url: http://localhost:8080/v1.0 - -paths: - /datetime: - get: - summary: Generate data with date time - operationId: fakeapi.hello.get_datetime - responses: - 200: - description: date time example - content: - application/json: - schema: - type: object - properties: - value: - type: string - format: date-time - example: - value: 2000-01-23T04:56:07.000008+00:00 - /date: - get: - summary: Generate data with date - operationId: fakeapi.hello.get_date - responses: - 200: - description: date example - content: - application/json: - schema: - type: object - properties: - value: - type: string - format: date - example: - value: 2000-01-23 - /uuid: - get: - summary: Generate data with uuid - operationId: fakeapi.hello.get_uuid - responses: - 200: - description: uuid example - content: - application/json: - schema: - type: object - properties: - value: - type: string - format: uuid - example: - value: 'a7b8869c-5f24-4ce0-a5d1-3e44c3663aa9' +openapi: "3.0.1" + +info: + title: "{{title}}" + version: "1.0" +servers: + - url: http://localhost:8080/v1.0 + +paths: + /datetime: + get: + summary: Generate data with date time + operationId: fakeapi.hello.get_datetime + responses: + 200: + description: date time example + content: + application/json: + schema: + type: object + properties: + value: + type: string + format: date-time + example: + value: 2000-01-23T04:56:07.000008+00:00 + /date: + get: + summary: Generate data with date + operationId: fakeapi.hello.get_date + responses: + 200: + description: date example + content: + application/json: + schema: + type: object + properties: + value: + type: string + format: date + example: + value: 2000-01-23 + /uuid: + get: + summary: Generate data with uuid + operationId: fakeapi.hello.get_uuid + responses: + 200: + description: uuid example + content: + application/json: + schema: + type: object + properties: + value: + type: string + format: uuid + example: + value: 'a7b8869c-5f24-4ce0-a5d1-3e44c3663aa9' diff --git a/tests/fixtures/datetime_support/swagger.yaml b/tests/fixtures/datetime_support/swagger.yaml index c73c2aa..5d388af 100644 --- a/tests/fixtures/datetime_support/swagger.yaml +++ b/tests/fixtures/datetime_support/swagger.yaml @@ -1,52 +1,52 @@ -swagger: "2.0" - -info: - title: "{{title}}" - version: "1.0" - -basePath: /v1.0 - -paths: - /datetime: - get: - operationId: fakeapi.hello.get_datetime - responses: - 200: - description: date time example - schema: - type: object - properties: - value: - type: string - format: date-time - example: - value: 2000-01-23T04:56:07.000008+00:00 - /date: - get: - operationId: fakeapi.hello.get_date - responses: - 200: - description: date example - schema: - type: object - properties: - value: - type: string - format: date - example: - value: 2000-01-23 - /uuid: - get: - summary: Generate data with uuid - operationId: fakeapi.hello.get_uuid - responses: - 200: - description: uuid example - schema: - type: object - properties: - value: - type: string - format: uuid - example: - value: 'a7b8869c-5f24-4ce0-a5d1-3e44c3663aa9' +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /v1.0 + +paths: + /datetime: + get: + operationId: fakeapi.hello.get_datetime + responses: + 200: + description: date time example + schema: + type: object + properties: + value: + type: string + format: date-time + example: + value: 2000-01-23T04:56:07.000008+00:00 + /date: + get: + operationId: fakeapi.hello.get_date + responses: + 200: + description: date example + schema: + type: object + properties: + value: + type: string + format: date + example: + value: 2000-01-23 + /uuid: + get: + summary: Generate data with uuid + operationId: fakeapi.hello.get_uuid + responses: + 200: + description: uuid example + schema: + type: object + properties: + value: + type: string + format: uuid + example: + value: 'a7b8869c-5f24-4ce0-a5d1-3e44c3663aa9' diff --git a/tests/fixtures/default_param_error/openapi.yaml b/tests/fixtures/default_param_error/openapi.yaml index 187264e..6fd1e10 100644 --- a/tests/fixtures/default_param_error/openapi.yaml +++ b/tests/fixtures/default_param_error/openapi.yaml @@ -1,21 +1,21 @@ -openapi: 3.0.0 -info: - title: '{{title}}' - version: '1.0' -paths: - /default-param-query-does-not-match-type: - get: - summary: Default value does not match the param type - operationId: fakeapi.hello.test_default_missmatch_definition - responses: - '200': - description: OK - parameters: - - name: age - in: query - description: Simple age - schema: - type: integer - default: 'error' -servers: - - url: /v1.0 +openapi: 3.0.0 +info: + title: '{{title}}' + version: '1.0' +paths: + /default-param-query-does-not-match-type: + get: + summary: Default value does not match the param type + operationId: fakeapi.hello.test_default_missmatch_definition + responses: + '200': + description: OK + parameters: + - name: age + in: query + description: Simple age + schema: + type: integer + default: 'error' +servers: + - url: /v1.0 diff --git a/tests/fixtures/default_param_error/swagger.yaml b/tests/fixtures/default_param_error/swagger.yaml index da33a37..b5532ad 100644 --- a/tests/fixtures/default_param_error/swagger.yaml +++ b/tests/fixtures/default_param_error/swagger.yaml @@ -1,22 +1,22 @@ -swagger: "2.0" - -info: - title: "{{title}}" - version: "1.0" - -basePath: /v1.0 - -paths: - /default-param-query-does-not-match-type: - get: - summary: Default value does not match the param type - operationId: fakeapi.hello.test_default_missmatch_definition - responses: - 200: - description: OK - parameters: - - name: age - in: query - type: integer - description: Simple age - default: 'error' +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /v1.0 + +paths: + /default-param-query-does-not-match-type: + get: + summary: Default value does not match the param type + operationId: fakeapi.hello.test_default_missmatch_definition + responses: + 200: + description: OK + parameters: + - name: age + in: query + type: integer + description: Simple age + default: 'error' diff --git a/tests/fixtures/different_schemas/openapi.yaml b/tests/fixtures/different_schemas/openapi.yaml index 3b87174..2707daf 100644 --- a/tests/fixtures/different_schemas/openapi.yaml +++ b/tests/fixtures/different_schemas/openapi.yaml @@ -1,304 +1,304 @@ -openapi: 3.0.0 -info: - title: '{{title}}' - version: '1.0' -servers: - - url: /v1.0 -paths: - /test_schema: - post: - summary: Returns the image_version - description: Returns the image_version - operationId: fakeapi.hello.schema - responses: - '200': - description: goodbye response - content: - application/json: - schema: - $ref: '#/components/schemas/new_stack' - requestBody: - content: - application/json: - schema: - x-body-name: new_stack - $ref: '#/components/schemas/new_stack' - required: true - '/test_schema/response/object/{valid}': - get: - summary: Returns an image_version as an object - description: Returns image_version - operationId: fakeapi.hello.schema_response_object - parameters: - - name: valid - in: path - description: Whether to return a valid or invalid schema in the response body - required: true - schema: - type: string - responses: - '200': - description: Requested new_stack data model - content: - application/json: - schema: - $ref: '#/components/schemas/new_stack' - '/test_schema/response/string/{valid}': - get: - summary: Returns an image_version as a string - description: Returns image_version - operationId: fakeapi.hello.schema_response_string - parameters: - - name: valid - in: path - description: Whether to return a valid or invalid schema in the response body - required: true - schema: - type: string - responses: - '200': - description: Requested new_stack data model - content: - text/plain: - schema: - type: string - '/test_schema/response/integer/{valid}': - get: - summary: Returns an image_version as an integer - description: Returns image_version - operationId: fakeapi.hello.schema_response_integer - parameters: - - name: valid - in: path - description: Whether to return a valid or invalid schema in the response body - required: true - schema: - type: string - responses: - '200': - description: Requested new_stack data model - content: - text/plain: - schema: - type: integer - '/test_schema/response/number/{valid}': - get: - summary: Returns an image_version as a number(float) - description: Returns image_version - operationId: fakeapi.hello.schema_response_number - parameters: - - name: valid - in: path - description: Whether to return a valid or invalid schema in the response body - required: true - schema: - type: string - responses: - '200': - description: Requested new_stack data model - content: - text/plain: - schema: - type: number - '/test_schema/response/boolean/{valid}': - get: - summary: Returns an image_version as a boolean - description: Returns image_version - operationId: fakeapi.hello.schema_response_boolean - parameters: - - name: valid - in: path - description: Whether to return a valid or invalid schema in the response body - required: true - schema: - type: string - responses: - '200': - description: Requested new_stack data model - content: - text/plain: - schema: - type: boolean - '/test_schema/response/array/{valid}': - get: - summary: Returns an image_version as a boolean - description: Returns image_version - operationId: fakeapi.hello.schema_response_array - parameters: - - name: valid - in: path - description: Whether to return a valid or invalid schema in the response body - required: true - schema: - type: string - responses: - '200': - description: Requested new_stack data model - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/new_stack' - /test_schema_in_query: - post: - summary: Returns the image_version - description: Returns the image_version - operationId: fakeapi.hello.schema_query - parameters: - - name: image_version - required: true - in: query - schema: - type: string - - name: not_required - required: false - in: query - schema: - type: string - responses: - '200': - description: goodbye response - content: - application/json: - schema: - $ref: '#/components/schemas/new_stack' - /test_schema_list: - post: - summary: Returns empty response - description: Returns empty response - operationId: fakeapi.hello.schema_list - responses: - '200': - description: goodbye response - content: - application/json: - schema: - type: string - requestBody: - $ref: '#/components/requestBodies/fakeapi.hello.schema_listNewStack' - /test_schema_map: - post: - summary: Returns empty response - description: Returns empty response - operationId: fakeapi.hello.schema_map - responses: - '200': - description: goodbye response - content: - application/json: - schema: - type: string - requestBody: - content: - application/json: - schema: - type: object - additionalProperties: - $ref: '#/components/schemas/new_stack' - required: true - /test_schema_recursive: - post: - summary: Returns empty response - description: Returns empty response - operationId: fakeapi.hello.schema_recursive - responses: - '200': - description: goodbye response - content: - application/json: - schema: - type: string - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/simple_tree' - required: true - /test_schema_format: - post: - summary: Returns empty response - description: Returns empty response - operationId: fakeapi.hello.schema_format - responses: - '200': - description: goodbye response - content: - application/json: - schema: - type: string - requestBody: - content: - application/json: - schema: - type: string - format: email - required: true - /schema_int: - post: - description: test schema int - operationId: fakeapi.hello.test_schema_int - responses: - '200': - description: OK - requestBody: - content: - application/json: - schema: - x-body-name: test_int - type: integer - required: true - /schema_array: - post: - description: test schema array - operationId: fakeapi.hello.test_schema_array - responses: - '200': - description: OK - requestBody: - $ref: '#/components/requestBodies/fakeapi.hello.schema_listNewStack' - /define_global_response: - get: - description: Should allow global response definitions - operationId: fakeapi.hello.test_global_response_definition - responses: - '200': - $ref: '#/components/responses/GeneralList' -components: - responses: - GeneralList: - description: A nice string array - content: - 'application/json': - schema: - type: array - items: - type: string - requestBodies: - fakeapi.hello.schema_listNewStack: - content: - application/json: - schema: - x-body-name: test_array - type: array - items: - type: string - required: true - schemas: - new_stack: - type: object - properties: - image_version: - type: string - description: Docker image version to deploy - required: - - image_version - simple_tree: - type: object - properties: - children: - type: array - items: - $ref: '#/components/schemas/simple_tree' - description: Docker image version to deploy - additionalProperties: false +openapi: 3.0.0 +info: + title: '{{title}}' + version: '1.0' +servers: + - url: /v1.0 +paths: + /test_schema: + post: + summary: Returns the image_version + description: Returns the image_version + operationId: fakeapi.hello.schema + responses: + '200': + description: goodbye response + content: + application/json: + schema: + $ref: '#/components/schemas/new_stack' + requestBody: + content: + application/json: + schema: + x-body-name: new_stack + $ref: '#/components/schemas/new_stack' + required: true + '/test_schema/response/object/{valid}': + get: + summary: Returns an image_version as an object + description: Returns image_version + operationId: fakeapi.hello.schema_response_object + parameters: + - name: valid + in: path + description: Whether to return a valid or invalid schema in the response body + required: true + schema: + type: string + responses: + '200': + description: Requested new_stack data model + content: + application/json: + schema: + $ref: '#/components/schemas/new_stack' + '/test_schema/response/string/{valid}': + get: + summary: Returns an image_version as a string + description: Returns image_version + operationId: fakeapi.hello.schema_response_string + parameters: + - name: valid + in: path + description: Whether to return a valid or invalid schema in the response body + required: true + schema: + type: string + responses: + '200': + description: Requested new_stack data model + content: + text/plain: + schema: + type: string + '/test_schema/response/integer/{valid}': + get: + summary: Returns an image_version as an integer + description: Returns image_version + operationId: fakeapi.hello.schema_response_integer + parameters: + - name: valid + in: path + description: Whether to return a valid or invalid schema in the response body + required: true + schema: + type: string + responses: + '200': + description: Requested new_stack data model + content: + text/plain: + schema: + type: integer + '/test_schema/response/number/{valid}': + get: + summary: Returns an image_version as a number(float) + description: Returns image_version + operationId: fakeapi.hello.schema_response_number + parameters: + - name: valid + in: path + description: Whether to return a valid or invalid schema in the response body + required: true + schema: + type: string + responses: + '200': + description: Requested new_stack data model + content: + text/plain: + schema: + type: number + '/test_schema/response/boolean/{valid}': + get: + summary: Returns an image_version as a boolean + description: Returns image_version + operationId: fakeapi.hello.schema_response_boolean + parameters: + - name: valid + in: path + description: Whether to return a valid or invalid schema in the response body + required: true + schema: + type: string + responses: + '200': + description: Requested new_stack data model + content: + text/plain: + schema: + type: boolean + '/test_schema/response/array/{valid}': + get: + summary: Returns an image_version as a boolean + description: Returns image_version + operationId: fakeapi.hello.schema_response_array + parameters: + - name: valid + in: path + description: Whether to return a valid or invalid schema in the response body + required: true + schema: + type: string + responses: + '200': + description: Requested new_stack data model + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/new_stack' + /test_schema_in_query: + post: + summary: Returns the image_version + description: Returns the image_version + operationId: fakeapi.hello.schema_query + parameters: + - name: image_version + required: true + in: query + schema: + type: string + - name: not_required + required: false + in: query + schema: + type: string + responses: + '200': + description: goodbye response + content: + application/json: + schema: + $ref: '#/components/schemas/new_stack' + /test_schema_list: + post: + summary: Returns empty response + description: Returns empty response + operationId: fakeapi.hello.schema_list + responses: + '200': + description: goodbye response + content: + application/json: + schema: + type: string + requestBody: + $ref: '#/components/requestBodies/fakeapi.hello.schema_listNewStack' + /test_schema_map: + post: + summary: Returns empty response + description: Returns empty response + operationId: fakeapi.hello.schema_map + responses: + '200': + description: goodbye response + content: + application/json: + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + additionalProperties: + $ref: '#/components/schemas/new_stack' + required: true + /test_schema_recursive: + post: + summary: Returns empty response + description: Returns empty response + operationId: fakeapi.hello.schema_recursive + responses: + '200': + description: goodbye response + content: + application/json: + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/simple_tree' + required: true + /test_schema_format: + post: + summary: Returns empty response + description: Returns empty response + operationId: fakeapi.hello.schema_format + responses: + '200': + description: goodbye response + content: + application/json: + schema: + type: string + requestBody: + content: + application/json: + schema: + type: string + format: email + required: true + /schema_int: + post: + description: test schema int + operationId: fakeapi.hello.test_schema_int + responses: + '200': + description: OK + requestBody: + content: + application/json: + schema: + x-body-name: test_int + type: integer + required: true + /schema_array: + post: + description: test schema array + operationId: fakeapi.hello.test_schema_array + responses: + '200': + description: OK + requestBody: + $ref: '#/components/requestBodies/fakeapi.hello.schema_listNewStack' + /define_global_response: + get: + description: Should allow global response definitions + operationId: fakeapi.hello.test_global_response_definition + responses: + '200': + $ref: '#/components/responses/GeneralList' +components: + responses: + GeneralList: + description: A nice string array + content: + 'application/json': + schema: + type: array + items: + type: string + requestBodies: + fakeapi.hello.schema_listNewStack: + content: + application/json: + schema: + x-body-name: test_array + type: array + items: + type: string + required: true + schemas: + new_stack: + type: object + properties: + image_version: + type: string + description: Docker image version to deploy + required: + - image_version + simple_tree: + type: object + properties: + children: + type: array + items: + $ref: '#/components/schemas/simple_tree' + description: Docker image version to deploy + additionalProperties: false diff --git a/tests/fixtures/different_schemas/swagger.yaml b/tests/fixtures/different_schemas/swagger.yaml index 88f418b..ca9c799 100644 --- a/tests/fixtures/different_schemas/swagger.yaml +++ b/tests/fixtures/different_schemas/swagger.yaml @@ -1,311 +1,311 @@ -swagger: "2.0" - -info: - title: "{{title}}" - version: "1.0" - -basePath: /v1.0 - -responses: - GeneralList: - description: A nice string array - schema: - type: array - items: - type: string - -paths: - /test_schema: - post: - summary: Returns the image_version - description: Returns the image_version - operationId: fakeapi.hello.schema - parameters: - - name: new_stack - required: true - in: body - schema: - $ref: '#/definitions/new_stack' - produces: - - application/json - responses: - 200: - description: goodbye response - schema: - $ref: '#/definitions/new_stack' - - /test_schema/response/object/{valid}: - get: - summary: Returns an image_version as an object - description: Returns image_version - operationId: fakeapi.hello.schema_response_object - produces: - - application/json - parameters: - - name: valid - in: path - description: Whether to return a valid or invalid schema in the response body - required: true - type: string - responses: - 200: - description: Requested new_stack data model - schema: - $ref: '#/definitions/new_stack' - - /test_schema/response/string/{valid}: - get: - summary: Returns an image_version as a string - description: Returns image_version - operationId: fakeapi.hello.schema_response_string - produces: - - text/plain - parameters: - - name: valid - in: path - description: Whether to return a valid or invalid schema in the response body - required: true - type: string - responses: - 200: - description: Requested new_stack data model - schema: - type: string - - /test_schema/response/integer/{valid}: - get: - summary: Returns an image_version as an integer - description: Returns image_version - operationId: fakeapi.hello.schema_response_integer - produces: - - text/plain - parameters: - - name: valid - in: path - description: Whether to return a valid or invalid schema in the response body - required: true - type: string - responses: - 200: - description: Requested new_stack data model - schema: - type: integer - - /test_schema/response/number/{valid}: - get: - summary: Returns an image_version as a number(float) - description: Returns image_version - operationId: fakeapi.hello.schema_response_number - produces: - - text/plain - parameters: - - name: valid - in: path - description: Whether to return a valid or invalid schema in the response body - required: true - type: string - responses: - 200: - description: Requested new_stack data model - schema: - type: number - - /test_schema/response/boolean/{valid}: - get: - summary: Returns an image_version as a boolean - description: Returns image_version - operationId: fakeapi.hello.schema_response_boolean - produces: - - text/plain - parameters: - - name: valid - in: path - description: Whether to return a valid or invalid schema in the response body - required: true - type: string - responses: - 200: - description: Requested new_stack data model - schema: - type: boolean - - /test_schema/response/array/{valid}: - get: - summary: Returns an image_version as a boolean - description: Returns image_version - operationId: fakeapi.hello.schema_response_array - produces: - - application/json - parameters: - - name: valid - in: path - description: Whether to return a valid or invalid schema in the response body - required: true - type: string - responses: - 200: - description: Requested new_stack data model - schema: - type: array - items: - $ref: '#/definitions/new_stack' - - /test_schema_in_query: - post: - summary: Returns the image_version - description: Returns the image_version - operationId: fakeapi.hello.schema_query - parameters: - - name: image_version - required: true - in: query - type: string - - name: not_required - required: false - in: query - type: string - produces: - - application/json - responses: - 200: - description: goodbye response - schema: - $ref: '#/definitions/new_stack' - - /test_schema_list: - post: - summary: Returns empty response - description: Returns empty response - operationId: fakeapi.hello.schema_list - parameters: - - name: new_stack - required: true - in: body - schema: - type: array - items: - type: string - produces: - - application/json - responses: - 200: - description: goodbye response - schema: - type: string - - /test_schema_map: - post: - summary: Returns empty response - description: Returns empty response - operationId: fakeapi.hello.schema_map - parameters: - - name: new_stack - required: true - in: body - schema: - type: object - additionalProperties: - $ref: '#/definitions/new_stack' - produces: - - application/json - responses: - 200: - description: goodbye response - schema: - type: string - - /test_schema_recursive: - post: - summary: Returns empty response - description: Returns empty response - operationId: fakeapi.hello.schema_recursive - parameters: - - name: new_stack - required: true - in: body - schema: - $ref: '#/definitions/simple_tree' - produces: - - application/json - responses: - 200: - description: goodbye response - schema: - type: string - - /test_schema_format: - post: - summary: Returns empty response - description: Returns empty response - operationId: fakeapi.hello.schema_format - parameters: - - name: timestamp - required: true - in: body - schema: - type: string - format: email - produces: - - application/json - responses: - 200: - description: goodbye response - schema: - type: string - - /schema_int: - post: - description: test schema int - operationId: fakeapi.hello.test_schema_int - parameters: - - name: test_int - in: body - required: true - schema: - type: integer - responses: - 200: - description: OK - - /schema_array: - post: - description: test schema array - operationId: fakeapi.hello.test_schema_array - parameters: - - name: test_array - in: body - required: true - schema: - type: array - items: - type: string - responses: - 200: - description: OK - - /define_global_response: - get: - description: Should allow global response definitions - operationId: fakeapi.hello.test_global_response_definition - responses: - 200: - $ref: '#/responses/GeneralList' - -definitions: - new_stack: - type: object - properties: - image_version: - type: string - description: Docker image version to deploy - required: - - image_version - simple_tree: - type: object - properties: - children: - type: array - items: - $ref: "#/definitions/simple_tree" - description: Docker image version to deploy - additionalProperties: false +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /v1.0 + +responses: + GeneralList: + description: A nice string array + schema: + type: array + items: + type: string + +paths: + /test_schema: + post: + summary: Returns the image_version + description: Returns the image_version + operationId: fakeapi.hello.schema + parameters: + - name: new_stack + required: true + in: body + schema: + $ref: '#/definitions/new_stack' + produces: + - application/json + responses: + 200: + description: goodbye response + schema: + $ref: '#/definitions/new_stack' + + /test_schema/response/object/{valid}: + get: + summary: Returns an image_version as an object + description: Returns image_version + operationId: fakeapi.hello.schema_response_object + produces: + - application/json + parameters: + - name: valid + in: path + description: Whether to return a valid or invalid schema in the response body + required: true + type: string + responses: + 200: + description: Requested new_stack data model + schema: + $ref: '#/definitions/new_stack' + + /test_schema/response/string/{valid}: + get: + summary: Returns an image_version as a string + description: Returns image_version + operationId: fakeapi.hello.schema_response_string + produces: + - text/plain + parameters: + - name: valid + in: path + description: Whether to return a valid or invalid schema in the response body + required: true + type: string + responses: + 200: + description: Requested new_stack data model + schema: + type: string + + /test_schema/response/integer/{valid}: + get: + summary: Returns an image_version as an integer + description: Returns image_version + operationId: fakeapi.hello.schema_response_integer + produces: + - text/plain + parameters: + - name: valid + in: path + description: Whether to return a valid or invalid schema in the response body + required: true + type: string + responses: + 200: + description: Requested new_stack data model + schema: + type: integer + + /test_schema/response/number/{valid}: + get: + summary: Returns an image_version as a number(float) + description: Returns image_version + operationId: fakeapi.hello.schema_response_number + produces: + - text/plain + parameters: + - name: valid + in: path + description: Whether to return a valid or invalid schema in the response body + required: true + type: string + responses: + 200: + description: Requested new_stack data model + schema: + type: number + + /test_schema/response/boolean/{valid}: + get: + summary: Returns an image_version as a boolean + description: Returns image_version + operationId: fakeapi.hello.schema_response_boolean + produces: + - text/plain + parameters: + - name: valid + in: path + description: Whether to return a valid or invalid schema in the response body + required: true + type: string + responses: + 200: + description: Requested new_stack data model + schema: + type: boolean + + /test_schema/response/array/{valid}: + get: + summary: Returns an image_version as a boolean + description: Returns image_version + operationId: fakeapi.hello.schema_response_array + produces: + - application/json + parameters: + - name: valid + in: path + description: Whether to return a valid or invalid schema in the response body + required: true + type: string + responses: + 200: + description: Requested new_stack data model + schema: + type: array + items: + $ref: '#/definitions/new_stack' + + /test_schema_in_query: + post: + summary: Returns the image_version + description: Returns the image_version + operationId: fakeapi.hello.schema_query + parameters: + - name: image_version + required: true + in: query + type: string + - name: not_required + required: false + in: query + type: string + produces: + - application/json + responses: + 200: + description: goodbye response + schema: + $ref: '#/definitions/new_stack' + + /test_schema_list: + post: + summary: Returns empty response + description: Returns empty response + operationId: fakeapi.hello.schema_list + parameters: + - name: new_stack + required: true + in: body + schema: + type: array + items: + type: string + produces: + - application/json + responses: + 200: + description: goodbye response + schema: + type: string + + /test_schema_map: + post: + summary: Returns empty response + description: Returns empty response + operationId: fakeapi.hello.schema_map + parameters: + - name: new_stack + required: true + in: body + schema: + type: object + additionalProperties: + $ref: '#/definitions/new_stack' + produces: + - application/json + responses: + 200: + description: goodbye response + schema: + type: string + + /test_schema_recursive: + post: + summary: Returns empty response + description: Returns empty response + operationId: fakeapi.hello.schema_recursive + parameters: + - name: new_stack + required: true + in: body + schema: + $ref: '#/definitions/simple_tree' + produces: + - application/json + responses: + 200: + description: goodbye response + schema: + type: string + + /test_schema_format: + post: + summary: Returns empty response + description: Returns empty response + operationId: fakeapi.hello.schema_format + parameters: + - name: timestamp + required: true + in: body + schema: + type: string + format: email + produces: + - application/json + responses: + 200: + description: goodbye response + schema: + type: string + + /schema_int: + post: + description: test schema int + operationId: fakeapi.hello.test_schema_int + parameters: + - name: test_int + in: body + required: true + schema: + type: integer + responses: + 200: + description: OK + + /schema_array: + post: + description: test schema array + operationId: fakeapi.hello.test_schema_array + parameters: + - name: test_array + in: body + required: true + schema: + type: array + items: + type: string + responses: + 200: + description: OK + + /define_global_response: + get: + description: Should allow global response definitions + operationId: fakeapi.hello.test_global_response_definition + responses: + 200: + $ref: '#/responses/GeneralList' + +definitions: + new_stack: + type: object + properties: + image_version: + type: string + description: Docker image version to deploy + required: + - image_version + simple_tree: + type: object + properties: + children: + type: array + items: + $ref: "#/definitions/simple_tree" + description: Docker image version to deploy + additionalProperties: false diff --git a/tests/fixtures/invalid_schema/swagger.yaml b/tests/fixtures/invalid_schema/swagger.yaml index 73505ed..81e3949 100644 --- a/tests/fixtures/invalid_schema/swagger.yaml +++ b/tests/fixtures/invalid_schema/swagger.yaml @@ -1,12 +1,12 @@ -swagger: "2.0" - -info: - title: "Bar" - version: "1.0" - -basePath: /v1.0 - -paths: - /foobar: - post: - invalidValue +swagger: "2.0" + +info: + title: "Bar" + version: "1.0" + +basePath: /v1.0 + +paths: + /foobar: + post: + invalidValue diff --git a/tests/fixtures/json_validation/openapi.yaml b/tests/fixtures/json_validation/openapi.yaml index 2e1fd18..3f1d3bd 100644 --- a/tests/fixtures/json_validation/openapi.yaml +++ b/tests/fixtures/json_validation/openapi.yaml @@ -1,121 +1,121 @@ -openapi: "3.0.0" - -info: - title: "{{title}}" - version: "1.0" - -servers: - - url: /v1.0 - -components: - schemas: - User: - type: object - required: - - name - - user_id - - password - properties: - user_id: - type: integer - readOnly: true - name: - type: string - password: - type: string - writeOnly: true - X: - type: object - properties: - name: - type: string - age: - type: integer - -paths: - /minlength: - post: - operationId: fakeapi.hello.post - requestBody: - content: - application/json: - schema: - type: object - properties: - foo: - type: string - responses: - 200: - description: Success - - /user: - get: - operationId: fakeapi.hello.get_user - responses: - 200: - description: User object - content: - application/json: - schema: - $ref: '#/components/schemas/User' - post: - operationId: fakeapi.hello.post_user - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/User' - responses: - 200: - description: User object - content: - application/json: - schema: - $ref: '#/components/schemas/User' - /user_with_password: - get: - operationId: fakeapi.hello.get_user_with_password - responses: - 200: - description: User object - content: - application/json: - schema: - $ref: '#/components/schemas/User' - - /nullable_default: - get: - operationId: fakeapi.hello.nullable_default - parameters: - - name: test - in: query - schema: - type: string - nullable: true - default: null - responses: - 204: - description: OK - - /multipart_form_json: - post: - operationId: fakeapi.hello.post_multipart_form - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - properties: - x: - $ref: "#/components/schemas/X" - encoding: - x: - contentType: "application/json" - responses: - 200: - description: Modified Echo - content: - application/json: - schema: - $ref: "#/components/schemas/X" +openapi: "3.0.0" + +info: + title: "{{title}}" + version: "1.0" + +servers: + - url: /v1.0 + +components: + schemas: + User: + type: object + required: + - name + - user_id + - password + properties: + user_id: + type: integer + readOnly: true + name: + type: string + password: + type: string + writeOnly: true + X: + type: object + properties: + name: + type: string + age: + type: integer + +paths: + /minlength: + post: + operationId: fakeapi.hello.post + requestBody: + content: + application/json: + schema: + type: object + properties: + foo: + type: string + responses: + 200: + description: Success + + /user: + get: + operationId: fakeapi.hello.get_user + responses: + 200: + description: User object + content: + application/json: + schema: + $ref: '#/components/schemas/User' + post: + operationId: fakeapi.hello.post_user + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + 200: + description: User object + content: + application/json: + schema: + $ref: '#/components/schemas/User' + /user_with_password: + get: + operationId: fakeapi.hello.get_user_with_password + responses: + 200: + description: User object + content: + application/json: + schema: + $ref: '#/components/schemas/User' + + /nullable_default: + get: + operationId: fakeapi.hello.nullable_default + parameters: + - name: test + in: query + schema: + type: string + nullable: true + default: null + responses: + 204: + description: OK + + /multipart_form_json: + post: + operationId: fakeapi.hello.post_multipart_form + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + x: + $ref: "#/components/schemas/X" + encoding: + x: + contentType: "application/json" + responses: + 200: + description: Modified Echo + content: + application/json: + schema: + $ref: "#/components/schemas/X" diff --git a/tests/fixtures/json_validation/swagger.yaml b/tests/fixtures/json_validation/swagger.yaml index ed34c88..0f9bcb5 100644 --- a/tests/fixtures/json_validation/swagger.yaml +++ b/tests/fixtures/json_validation/swagger.yaml @@ -1,82 +1,82 @@ -swagger: "2.0" - -info: - title: "{{title}}" - version: "1.0" - -basePath: /v1.0 - -definitions: - User: - type: object - required: - - name - properties: - user_id: - type: integer - readOnly: true - name: - type: string - password: - type: string - x-writeOnly: true - -paths: - /minlength: - post: - operationId: fakeapi.hello.post - parameters: - - name: body - in: body - required: true - schema: - type: object - properties: - foo: - type: string - responses: - 200: - description: Success - - /user: - get: - operationId: fakeapi.hello.get_user - responses: - 200: - description: User object - schema: - $ref: '#/definitions/User' - post: - operationId: fakeapi.hello.post_user - parameters: - - name: body - in: body - required: true - schema: - $ref: '#/definitions/User' - responses: - 200: - description: User object - schema: - $ref: '#/definitions/User' - /user_with_password: - get: - operationId: fakeapi.hello.get_user_with_password - responses: - 200: - description: User object - schema: - $ref: '#/definitions/User' - - /nullable_default: - get: - operationId: fakeapi.hello.nullable_default - parameters: - - name: test - in: query - type: string - x-nullable: true - default: null - responses: - 204: - description: OK +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /v1.0 + +definitions: + User: + type: object + required: + - name + properties: + user_id: + type: integer + readOnly: true + name: + type: string + password: + type: string + x-writeOnly: true + +paths: + /minlength: + post: + operationId: fakeapi.hello.post + parameters: + - name: body + in: body + required: true + schema: + type: object + properties: + foo: + type: string + responses: + 200: + description: Success + + /user: + get: + operationId: fakeapi.hello.get_user + responses: + 200: + description: User object + schema: + $ref: '#/definitions/User' + post: + operationId: fakeapi.hello.post_user + parameters: + - name: body + in: body + required: true + schema: + $ref: '#/definitions/User' + responses: + 200: + description: User object + schema: + $ref: '#/definitions/User' + /user_with_password: + get: + operationId: fakeapi.hello.get_user_with_password + responses: + 200: + description: User object + schema: + $ref: '#/definitions/User' + + /nullable_default: + get: + operationId: fakeapi.hello.nullable_default + parameters: + - name: test + in: query + type: string + x-nullable: true + default: null + responses: + 204: + description: OK diff --git a/tests/fixtures/missing_implementation/openapi.yaml b/tests/fixtures/missing_implementation/openapi.yaml index c47e5fd..7f39e46 100644 --- a/tests/fixtures/missing_implementation/openapi.yaml +++ b/tests/fixtures/missing_implementation/openapi.yaml @@ -1,14 +1,14 @@ -openapi: 3.0.0 -info: - title: Testing API - version: '1.0' -paths: - /operation-not-implemented: - get: - summary: Operation function does not exist. - operationId: api.this_function_does_not_exist - responses: - '200': - description: OK -servers: - - url: /testing +openapi: 3.0.0 +info: + title: Testing API + version: '1.0' +paths: + /operation-not-implemented: + get: + summary: Operation function does not exist. + operationId: api.this_function_does_not_exist + responses: + '200': + description: OK +servers: + - url: /testing diff --git a/tests/fixtures/missing_implementation/swagger.yaml b/tests/fixtures/missing_implementation/swagger.yaml index e2665ce..64d92b4 100644 --- a/tests/fixtures/missing_implementation/swagger.yaml +++ b/tests/fixtures/missing_implementation/swagger.yaml @@ -1,16 +1,16 @@ -swagger: "2.0" - -info: - title: "Testing API" - version: "1.0" - -basePath: "/testing" - -paths: - /operation-not-implemented: - get: - summary: Operation function does not exist. - operationId: api.this_function_does_not_exist - responses: - 200: - description: OK +swagger: "2.0" + +info: + title: "Testing API" + version: "1.0" + +basePath: "/testing" + +paths: + /operation-not-implemented: + get: + summary: Operation function does not exist. + operationId: api.this_function_does_not_exist + responses: + 200: + description: OK diff --git a/tests/fixtures/missing_op_id/openapi.yaml b/tests/fixtures/missing_op_id/openapi.yaml index ce881c3..7230be1 100644 --- a/tests/fixtures/missing_op_id/openapi.yaml +++ b/tests/fixtures/missing_op_id/openapi.yaml @@ -1,16 +1,16 @@ -openapi: 3.0.0 -info: - title: '{{title}}' - version: '1.0' -paths: - /welcome: - put: - responses: - '200': - description: greeting response - content: - '*/*': - schema: - type: object -servers: - - url: /v1.0 +openapi: 3.0.0 +info: + title: '{{title}}' + version: '1.0' +paths: + /welcome: + put: + responses: + '200': + description: greeting response + content: + '*/*': + schema: + type: object +servers: + - url: /v1.0 diff --git a/tests/fixtures/missing_op_id/swagger.yaml b/tests/fixtures/missing_op_id/swagger.yaml index 31c5dbd..b1a8afb 100644 --- a/tests/fixtures/missing_op_id/swagger.yaml +++ b/tests/fixtures/missing_op_id/swagger.yaml @@ -1,17 +1,17 @@ -swagger: "2.0" - -info: - title: "{{title}}" - version: "1.0" - -basePath: /v1.0 - -paths: - /welcome: - put: - # operationId: XXX completely missing - responses: - 200: - description: greeting response - schema: - type: object +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /v1.0 + +paths: + /welcome: + put: + # operationId: XXX completely missing + responses: + 200: + description: greeting response + schema: + type: object diff --git a/tests/fixtures/module_does_not_exist/openapi.yaml b/tests/fixtures/module_does_not_exist/openapi.yaml index 00ab59c..36354f7 100644 --- a/tests/fixtures/module_does_not_exist/openapi.yaml +++ b/tests/fixtures/module_does_not_exist/openapi.yaml @@ -1,19 +1,19 @@ -openapi: 3.0.0 -info: - title: Not Exist API - version: '1.0' -paths: - /module-not-implemented/{some_path}: - get: - summary: Operation function does not exist. - operationId: m.module_does_not_exist - parameters: - - in: path - name: some_path - schema: - type: string - responses: - '200': - description: OK -servers: - - url: /na +openapi: 3.0.0 +info: + title: Not Exist API + version: '1.0' +paths: + /module-not-implemented/{some_path}: + get: + summary: Operation function does not exist. + operationId: m.module_does_not_exist + parameters: + - in: path + name: some_path + schema: + type: string + responses: + '200': + description: OK +servers: + - url: /na diff --git a/tests/fixtures/module_does_not_exist/swagger.yaml b/tests/fixtures/module_does_not_exist/swagger.yaml index 2562695..9405c8b 100644 --- a/tests/fixtures/module_does_not_exist/swagger.yaml +++ b/tests/fixtures/module_does_not_exist/swagger.yaml @@ -1,21 +1,21 @@ -swagger: "2.0" - -info: - title: "Not Exist API" - version: "1.0" - -basePath: '/na' - -paths: - /module-not-implemented/{some_path}: - get: - summary: Operation function does not exist. - operationId: m.module_does_not_exist - parameters: - - name: some_path - in: path - required: true - type: string - responses: - 200: - description: OK +swagger: "2.0" + +info: + title: "Not Exist API" + version: "1.0" + +basePath: '/na' + +paths: + /module-not-implemented/{some_path}: + get: + summary: Operation function does not exist. + operationId: m.module_does_not_exist + parameters: + - name: some_path + in: path + required: true + type: string + responses: + 200: + description: OK diff --git a/tests/fixtures/module_not_implemented/openapi.yaml b/tests/fixtures/module_not_implemented/openapi.yaml index 66b5f00..f2b3b57 100644 --- a/tests/fixtures/module_not_implemented/openapi.yaml +++ b/tests/fixtures/module_not_implemented/openapi.yaml @@ -1,17 +1,17 @@ -openapi: 3.0.0 -info: - title: '{{title}}' - version: '1.0' -paths: - /welcome: - get: - operationId: no.module.or.function - responses: - '200': - description: greeting response - content: - '*/*': - schema: - type: object -servers: - - url: /v1.0 +openapi: 3.0.0 +info: + title: '{{title}}' + version: '1.0' +paths: + /welcome: + get: + operationId: no.module.or.function + responses: + '200': + description: greeting response + content: + '*/*': + schema: + type: object +servers: + - url: /v1.0 diff --git a/tests/fixtures/module_not_implemented/swagger.yaml b/tests/fixtures/module_not_implemented/swagger.yaml index 366f77f..4f30129 100644 --- a/tests/fixtures/module_not_implemented/swagger.yaml +++ b/tests/fixtures/module_not_implemented/swagger.yaml @@ -1,17 +1,17 @@ -swagger: "2.0" - -info: - title: "{{title}}" - version: "1.0" - -basePath: /v1.0 - -paths: - /welcome: - get: - operationId: no.module.or.function - responses: - 200: - description: greeting response - schema: - type: object +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /v1.0 + +paths: + /welcome: + get: + operationId: no.module.or.function + responses: + 200: + description: greeting response + schema: + type: object diff --git a/tests/fixtures/op_error_api/openapi.yaml b/tests/fixtures/op_error_api/openapi.yaml index 4776f97..570510f 100644 --- a/tests/fixtures/op_error_api/openapi.yaml +++ b/tests/fixtures/op_error_api/openapi.yaml @@ -1,17 +1,17 @@ -openapi: 3.0.0 -info: - title: '{{title}}' - version: '1.0' -paths: - /welcome: - get: - operationId: fakeapi.module_with_error.something - responses: - '200': - description: greeting response - content: - '*/*': - schema: - type: object -servers: - - url: /v1.0 +openapi: 3.0.0 +info: + title: '{{title}}' + version: '1.0' +paths: + /welcome: + get: + operationId: fakeapi.module_with_error.something + responses: + '200': + description: greeting response + content: + '*/*': + schema: + type: object +servers: + - url: /v1.0 diff --git a/tests/fixtures/op_error_api/swagger.yaml b/tests/fixtures/op_error_api/swagger.yaml index 0e22d26..3478952 100644 --- a/tests/fixtures/op_error_api/swagger.yaml +++ b/tests/fixtures/op_error_api/swagger.yaml @@ -1,17 +1,17 @@ -swagger: "2.0" - -info: - title: "{{title}}" - version: "1.0" - -basePath: /v1.0 - -paths: - /welcome: - get: - operationId: fakeapi.module_with_error.something - responses: - 200: - description: greeting response - schema: - type: object +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /v1.0 + +paths: + /welcome: + get: + operationId: fakeapi.module_with_error.something + responses: + 200: + description: greeting response + schema: + type: object diff --git a/tests/fixtures/problem/openapi.yaml b/tests/fixtures/problem/openapi.yaml index 681b80b..3bc02cd 100644 --- a/tests/fixtures/problem/openapi.yaml +++ b/tests/fixtures/problem/openapi.yaml @@ -1,105 +1,105 @@ -openapi: 3.0.0 -info: - title: '{{title}}' - version: '1.0' -paths: - '/greeting/{name}': - post: - summary: Generate greeting - description: Generates a greeting message. - operationId: fakeapi.hello.post_greeting - responses: - '200': - description: greeting response - content: - '*/*': - schema: - type: object - parameters: - - name: name - in: path - description: Name of the person to greet. - required: true - schema: - type: string - /except: - get: - summary: Fails badly - description: Fails badly - operationId: fakeapi.hello.internal_error - responses: - '200': - description: goodbye response - content: - text/plain: - schema: - type: string - /problem: - get: - summary: Fails - description: Fails - operationId: fakeapi.hello.with_problem - responses: - '200': - description: goodbye response - content: - application/json: - schema: - type: string - /other_problem: - get: - summary: Fails - description: Fails - operationId: fakeapi.hello.with_problem_txt - responses: - '200': - description: goodbye response - content: - text/plain: - schema: - type: string - /json_response_with_undefined_value_to_serialize: - get: - description: Will fail - operationId: fakeapi.hello.get_invalid_response - responses: - '200': - description: Never happens - /customized_problem_response: - get: - description: Custom problem response - operationId: fakeapi.hello.get_custom_problem_response - responses: - '200': - description: Custom problem response - /problem_exception_with_extra_args: - get: - description: Using problem as exception - operationId: fakeapi.hello.throw_problem_exception - responses: - '200': - description: Problem exception - /post_wrong_content_type: - post: - description: Unsupported media type - operationId: fakeapi.hello.post_wrong_content_type - requestBody: - content: - application/json: - schema: - type: object - responses: - 200: - description: OK -servers: - - url: /v1.0 -components: - securitySchemes: - oauth: - type: oauth2 - x-tokenInfoUrl: 'https://oauth.example/token_info' - flows: - password: - tokenUrl: 'https://oauth.example/token' - scopes: - myscope: can do stuff +openapi: 3.0.0 +info: + title: '{{title}}' + version: '1.0' +paths: + '/greeting/{name}': + post: + summary: Generate greeting + description: Generates a greeting message. + operationId: fakeapi.hello.post_greeting + responses: + '200': + description: greeting response + content: + '*/*': + schema: + type: object + parameters: + - name: name + in: path + description: Name of the person to greet. + required: true + schema: + type: string + /except: + get: + summary: Fails badly + description: Fails badly + operationId: fakeapi.hello.internal_error + responses: + '200': + description: goodbye response + content: + text/plain: + schema: + type: string + /problem: + get: + summary: Fails + description: Fails + operationId: fakeapi.hello.with_problem + responses: + '200': + description: goodbye response + content: + application/json: + schema: + type: string + /other_problem: + get: + summary: Fails + description: Fails + operationId: fakeapi.hello.with_problem_txt + responses: + '200': + description: goodbye response + content: + text/plain: + schema: + type: string + /json_response_with_undefined_value_to_serialize: + get: + description: Will fail + operationId: fakeapi.hello.get_invalid_response + responses: + '200': + description: Never happens + /customized_problem_response: + get: + description: Custom problem response + operationId: fakeapi.hello.get_custom_problem_response + responses: + '200': + description: Custom problem response + /problem_exception_with_extra_args: + get: + description: Using problem as exception + operationId: fakeapi.hello.throw_problem_exception + responses: + '200': + description: Problem exception + /post_wrong_content_type: + post: + description: Unsupported media type + operationId: fakeapi.hello.post_wrong_content_type + requestBody: + content: + application/json: + schema: + type: object + responses: + 200: + description: OK +servers: + - url: /v1.0 +components: + securitySchemes: + oauth: + type: oauth2 + x-tokenInfoUrl: 'https://oauth.example/token_info' + flows: + password: + tokenUrl: 'https://oauth.example/token' + scopes: + myscope: can do stuff diff --git a/tests/fixtures/problem/swagger.yaml b/tests/fixtures/problem/swagger.yaml index 64efafa..c4e0f2b 100644 --- a/tests/fixtures/problem/swagger.yaml +++ b/tests/fixtures/problem/swagger.yaml @@ -1,119 +1,119 @@ -swagger: "2.0" - -info: - title: "{{title}}" - version: "1.0" - -basePath: /v1.0 - -securityDefinitions: - oauth: - type: oauth2 - flow: password - tokenUrl: https://oauth.example/token - x-tokenInfoUrl: https://oauth.example/token_info - scopes: - myscope: can do stuff - -paths: - /greeting/{name}: - post: - summary: Generate greeting - description: Generates a greeting message. - operationId: fakeapi.hello.post_greeting - responses: - 200: - description: greeting response - schema: - type: object - parameters: - - name: name - in: path - description: Name of the person to greet. - required: true - type: string - - /except: - get: - summary: Fails badly - description: Fails badly - operationId: fakeapi.hello.internal_error - produces: - - text/plain - responses: - 200: - description: goodbye response - schema: - type: string - - /problem: - get: - summary: Fails - description: Fails - operationId: fakeapi.hello.with_problem - produces: - - application/json - responses: - 200: - description: goodbye response - schema: - type: string - - /other_problem: - get: - summary: Fails - description: Fails - operationId: fakeapi.hello.with_problem_txt - produces: - - text/plain - responses: - 200: - description: goodbye response - schema: - type: string - - /json_response_with_undefined_value_to_serialize: - get: - description: Will fail - operationId: fakeapi.hello.get_invalid_response - produces: - - application/json - responses: - 200: - description: Never happens - - /customized_problem_response: - get: - description: Custom problem response - operationId: fakeapi.hello.get_custom_problem_response - produces: - - application/json - responses: - 200: - description: Custom problem response - - /problem_exception_with_extra_args: - get: - description: Using problem as exception - operationId: fakeapi.hello.throw_problem_exception - produces: - - application/json - responses: - 200: - description: Problem exception - - /post_wrong_content_type: - post: - description: Unsupported media type - operationId: fakeapi.hello.post_wrong_content_type - consumes: - - application/json - parameters: - - in: body - name: body - description: The request body - schema: - type: object - responses: - 200: - description: OK +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /v1.0 + +securityDefinitions: + oauth: + type: oauth2 + flow: password + tokenUrl: https://oauth.example/token + x-tokenInfoUrl: https://oauth.example/token_info + scopes: + myscope: can do stuff + +paths: + /greeting/{name}: + post: + summary: Generate greeting + description: Generates a greeting message. + operationId: fakeapi.hello.post_greeting + responses: + 200: + description: greeting response + schema: + type: object + parameters: + - name: name + in: path + description: Name of the person to greet. + required: true + type: string + + /except: + get: + summary: Fails badly + description: Fails badly + operationId: fakeapi.hello.internal_error + produces: + - text/plain + responses: + 200: + description: goodbye response + schema: + type: string + + /problem: + get: + summary: Fails + description: Fails + operationId: fakeapi.hello.with_problem + produces: + - application/json + responses: + 200: + description: goodbye response + schema: + type: string + + /other_problem: + get: + summary: Fails + description: Fails + operationId: fakeapi.hello.with_problem_txt + produces: + - text/plain + responses: + 200: + description: goodbye response + schema: + type: string + + /json_response_with_undefined_value_to_serialize: + get: + description: Will fail + operationId: fakeapi.hello.get_invalid_response + produces: + - application/json + responses: + 200: + description: Never happens + + /customized_problem_response: + get: + description: Custom problem response + operationId: fakeapi.hello.get_custom_problem_response + produces: + - application/json + responses: + 200: + description: Custom problem response + + /problem_exception_with_extra_args: + get: + description: Using problem as exception + operationId: fakeapi.hello.throw_problem_exception + produces: + - application/json + responses: + 200: + description: Problem exception + + /post_wrong_content_type: + post: + description: Unsupported media type + operationId: fakeapi.hello.post_wrong_content_type + consumes: + - application/json + parameters: + - in: body + name: body + description: The request body + schema: + type: object + responses: + 200: + description: OK diff --git a/tests/fixtures/secure_api/openapi.yaml b/tests/fixtures/secure_api/openapi.yaml index a3fbe81..952ce42 100644 --- a/tests/fixtures/secure_api/openapi.yaml +++ b/tests/fixtures/secure_api/openapi.yaml @@ -1,39 +1,39 @@ -openapi: 3.0.0 -servers: - - url: /v1.0 -info: - title: '{{title}}' - version: '1.0' -security: - - oauth: - - myscope -paths: - '/greeting/{name}': - post: - summary: Generate greeting - description: Generates a greeting message. - operationId: fakeapi.hello.post_greeting - responses: - '200': - description: greeting response - content: - '*/*': - schema: - type: object - parameters: - - name: name - in: path - description: Name of the person to greet. - required: true - schema: - type: string -components: - securitySchemes: - oauth: - type: oauth2 - x-tokenInfoUrl: 'https://oauth.example/token_info' - flows: - password: - tokenUrl: 'https://oauth.example/token' - scopes: - myscope: can do stuff +openapi: 3.0.0 +servers: + - url: /v1.0 +info: + title: '{{title}}' + version: '1.0' +security: + - oauth: + - myscope +paths: + '/greeting/{name}': + post: + summary: Generate greeting + description: Generates a greeting message. + operationId: fakeapi.hello.post_greeting + responses: + '200': + description: greeting response + content: + '*/*': + schema: + type: object + parameters: + - name: name + in: path + description: Name of the person to greet. + required: true + schema: + type: string +components: + securitySchemes: + oauth: + type: oauth2 + x-tokenInfoUrl: 'https://oauth.example/token_info' + flows: + password: + tokenUrl: 'https://oauth.example/token' + scopes: + myscope: can do stuff diff --git a/tests/fixtures/secure_api/swagger.yaml b/tests/fixtures/secure_api/swagger.yaml index 290caab..326d02a 100644 --- a/tests/fixtures/secure_api/swagger.yaml +++ b/tests/fixtures/secure_api/swagger.yaml @@ -1,38 +1,38 @@ -swagger: "2.0" - -info: - title: "{{title}}" - version: "1.0" - -basePath: /v1.0 - -securityDefinitions: - oauth: - type: oauth2 - flow: password - tokenUrl: https://oauth.example/token - x-tokenInfoUrl: https://oauth.example/token_info - scopes: - myscope: can do stuff - -security: - - oauth: - - myscope - -paths: - /greeting/{name}: - post: - summary: Generate greeting - description: Generates a greeting message. - operationId: fakeapi.hello.post_greeting - responses: - 200: - description: greeting response - schema: - type: object - parameters: - - name: name - in: path - description: Name of the person to greet. - required: true - type: string +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /v1.0 + +securityDefinitions: + oauth: + type: oauth2 + flow: password + tokenUrl: https://oauth.example/token + x-tokenInfoUrl: https://oauth.example/token_info + scopes: + myscope: can do stuff + +security: + - oauth: + - myscope + +paths: + /greeting/{name}: + post: + summary: Generate greeting + description: Generates a greeting message. + operationId: fakeapi.hello.post_greeting + responses: + 200: + description: greeting response + schema: + type: object + parameters: + - name: name + in: path + description: Name of the person to greet. + required: true + type: string diff --git a/tests/fixtures/secure_endpoint/openapi.yaml b/tests/fixtures/secure_endpoint/openapi.yaml index d9ef757..8dc79c0 100644 --- a/tests/fixtures/secure_endpoint/openapi.yaml +++ b/tests/fixtures/secure_endpoint/openapi.yaml @@ -1,163 +1,179 @@ -openapi: 3.0.0 -info: - title: '{{title}}' - version: '1.0' -paths: - '/byesecure/{name}': - get: - summary: Generate goodbye - description: Generates a goodbye message. - operationId: fakeapi.hello.get_bye_secure - security: - - oauth: - - myscope - responses: - '200': - description: goodbye response - content: - text/plain: - schema: - type: string - parameters: - - name: name - in: path - description: Name of the person to say bye. - required: true - schema: - type: string - /byesecure-from-flask: - get: - summary: Generate goodbye - description: '' - operationId: fakeapi.hello.get_bye_secure_from_flask - security: - - oauth: - - myscope - responses: - '200': - description: goodbye response - content: - text/plain: - schema: - type: string - /byesecure-from-firetail: - get: - summary: Generate goodbye - description: '' - operationId: fakeapi.hello.get_bye_secure_from_firetail - security: - - oauth: - - myscope - responses: - '200': - description: goodbye response - content: - text/plain: - schema: - type: string - '/byesecure-ignoring-context/{name}': - get: - summary: Generate goodbye - description: Generates a goodbye message. - operationId: fakeapi.hello.get_bye_secure_ignoring_context - security: - - oauth: - - myscope - responses: - '200': - description: goodbye response - content: - text/plain: - schema: - type: string - parameters: - - name: name - in: path - description: Name of the person to say bye. - required: true - schema: - type: string - '/byesecure-jwt/{name}': - get: - summary: Generate goodbye - description: Generates a goodbye message. - operationId: fakeapi.hello.get_bye_secure_jwt - security: - - jwt: [] - responses: - '200': - description: goodbye response - content: - text/plain: - schema: - type: string - parameters: - - name: name - in: path - description: Name of the person to say bye. - required: true - schema: - type: string - /more-than-one-security-definition: - get: - summary: Some external call to API - description: External application call - operationId: fakeapi.hello.schema_list - security: - - oauth: - - myscope - - api_key: [] - responses: - '200': - description: OK - /more-than-one-scope: - get: - summary: Test more than one scope - description: | - Test that firetail handles scopes properly by verifying that user has - all scopes necessary to call the endpoint. - operationId: fakeapi.hello.more_than_one_scope_defined - security: - - oauth: - - myscope - - otherscope - responses: - '200': - description: some response - /optional-auth: - get: - summary: Test empty security definition - description: | - Test that firetail handles an empty security definition correctly. - In case an empty definition is provided, the user is required to - apply proper authentication and authorization techniques. - operationId: fakeapi.hello.optional_auth - security: - - api_key: [] - - {} - responses: - '200': - description: some response -servers: - - url: /v1.0 -components: - securitySchemes: - oauth: - type: oauth2 - x-tokenInfoUrl: 'https://oauth.example/token_info' - flows: - password: - tokenUrl: 'https://oauth.example/token' - scopes: - myscope: can do stuff - otherscope: another scope - api_key: - type: apiKey - name: X-Auth - in: header - x-apikeyInfoFunc: fakeapi.hello.apikey_info - jwt: - type: http - scheme: bearer - bearerFormat: JWT - x-bearerInfoFunc: fakeapi.hello.jwt_info +openapi: 3.0.0 +info: + title: '{{title}}' + version: '1.0' +paths: + '/byesecure/{name}': + get: + summary: Generate goodbye + description: Generates a goodbye message. + operationId: fakeapi.hello.get_bye_secure + security: + - oauth: + - myscope + responses: + '200': + description: goodbye response + content: + text/plain: + schema: + type: string + parameters: + - name: name + in: path + description: Name of the person to say bye. + required: true + schema: + type: string + /byesecure-from-flask: + get: + summary: Generate goodbye + description: '' + operationId: fakeapi.hello.get_bye_secure_from_flask + security: + - oauth: + - myscope + responses: + '200': + description: goodbye response + content: + text/plain: + schema: + type: string + /byesecure-from-firetail: + get: + summary: Generate goodbye + description: '' + operationId: fakeapi.hello.get_bye_secure_from_firetail + security: + - oauth: + - myscope + responses: + '200': + description: goodbye response + content: + text/plain: + schema: + type: string + '/byesecure-ignoring-context/{name}': + get: + summary: Generate goodbye + description: Generates a goodbye message. + operationId: fakeapi.hello.get_bye_secure_ignoring_context + security: + - oauth: + - myscope + responses: + '200': + description: goodbye response + content: + text/plain: + schema: + type: string + parameters: + - name: name + in: path + description: Name of the person to say bye. + required: true + schema: + type: string + '/byesecure-jwt/{name}': + get: + summary: Generate goodbye + description: Generates a goodbye message. + operationId: fakeapi.hello.get_bye_secure_jwt + security: + - jwt: [] + responses: + '200': + description: goodbye response + content: + text/plain: + schema: + type: string + parameters: + - name: name + in: path + description: Name of the person to say bye. + required: true + schema: + type: string + /more-than-one-security-definition: + get: + summary: Some external call to API + description: External application call + operationId: fakeapi.hello.schema_list + security: + - oauth: + - myscope + - api_key: [] + responses: + '200': + description: OK + /more-than-one-scope: + get: + summary: Test more than one scope + description: | + Test that firetail handles scopes properly by verifying that user has + all scopes necessary to call the endpoint. + operationId: fakeapi.hello.more_than_one_scope_defined + security: + - oauth: + - myscope + - otherscope + responses: + '200': + description: some response + /optional-auth: + get: + summary: Test empty security definition + description: | + Test that firetail handles an empty security definition correctly. + In case an empty definition is provided, the user is required to + apply proper authentication and authorization techniques. + operationId: fakeapi.hello.optional_auth + security: + - api_key: [] + - {} + responses: + '200': + description: some response + /auth-exception: + get: + summary: Test security handler function that raises an exception + description: Throw error from security function + operationId: fakeapi.hello.auth_exception + security: + - auth_exception: [] + responses: + '200': + description: some response + +servers: + - url: /v1.0 +components: + securitySchemes: + oauth: + type: oauth2 + x-tokenInfoUrl: 'https://oauth.example/token_info' + flows: + password: + tokenUrl: 'https://oauth.example/token' + scopes: + myscope: can do stuff + otherscope: another scope + api_key: + type: apiKey + name: X-Auth + in: header + x-apikeyInfoFunc: fakeapi.hello.apikey_info + jwt: + type: http + scheme: bearer + bearerFormat: JWT + x-bearerInfoFunc: fakeapi.hello.jwt_info + auth_exception: + type: apiKey + name: X-Api-Key + in: header + x-apikeyInfoFunc: fakeapi.hello.apikey_exception diff --git a/tests/fixtures/secure_endpoint/swagger.yaml b/tests/fixtures/secure_endpoint/swagger.yaml index 2ac4fae..e522618 100644 --- a/tests/fixtures/secure_endpoint/swagger.yaml +++ b/tests/fixtures/secure_endpoint/swagger.yaml @@ -1,173 +1,190 @@ -swagger: "2.0" - -info: - title: "{{title}}" - version: "1.0" - -basePath: /v1.0 - -securityDefinitions: - oauth: - type: oauth2 - flow: password - tokenUrl: https://oauth.example/token - x-tokenInfoUrl: https://oauth.example/token_info - scopes: - myscope: can do stuff - otherscope: another scope - - api_key: - type: apiKey - name: X-Auth - in: header - x-apikeyInfoFunc: fakeapi.hello.apikey_info - - jwt: - type: apiKey - name: Authorization - in: header - x-authentication-scheme: Bearer - x-bearerInfoFunc: fakeapi.hello.jwt_info - -paths: - /byesecure/{name}: - get: - summary: Generate goodbye - description: Generates a goodbye message. - operationId: fakeapi.hello.get_bye_secure - security: - - oauth: - - myscope - produces: - - text/plain - responses: - 200: - description: goodbye response - schema: - type: string - parameters: - - name: name - in: path - description: Name of the person to say bye. - required: true - type: string - - /byesecure-from-flask: - get: - summary: Generate goodbye - description: "" - operationId: fakeapi.hello.get_bye_secure_from_flask - security: - - oauth: - - myscope - produces: - - text/plain - responses: - 200: - description: goodbye response - schema: - type: string - - /byesecure-from-firetail: - get: - summary: Generate goodbye - description: "" - operationId: fakeapi.hello.get_bye_secure_from_firetail - security: - - oauth: - - myscope - produces: - - text/plain - responses: - 200: - description: goodbye response - schema: - type: string - - /byesecure-ignoring-context/{name}: - get: - summary: Generate goodbye - description: Generates a goodbye message. - operationId: fakeapi.hello.get_bye_secure_ignoring_context - security: - - oauth: - - myscope - produces: - - text/plain - responses: - 200: - description: goodbye response - schema: - type: string - parameters: - - name: name - in: path - description: Name of the person to say bye. - required: true - type: string - - /byesecure-jwt/: - get: - summary: Generate goodbye - description: "" - operationId: fakeapi.hello.get_bye_secure_jwt - security: - - jwt: [] - produces: - - text/plain - responses: - 200: - description: goodbye response - schema: - type: string - parameters: - - name: name - in: path - description: Name of the person to say bye. - required: true - type: string - - /more-than-one-security-definition: - get: - summary: Some external call to API - description: External application call - operationId: fakeapi.hello.schema_list - security: - - oauth: - - myscope - - api_key: [] - responses: - 200: - description: OK - - /more-than-one-scope: - get: - summary: Test more than one scope - description: | - Test that firetail handles scopes properly by verifying that user has - all scopes necessary to call the endpoint. - operationId: fakeapi.hello.more_than_one_scope_defined - security: - - oauth: - - myscope - - otherscope - produces: - - text/plain - responses: - 200: - description: some response - - /optional-auth: - get: - summary: Test empty security definition - description: | - Test that firetail handles an empty security definition correctly. - In case an empty definition is provided, the user is required to - apply proper authentication and authorization techniques. - operationId: fakeapi.hello.optional_auth - security: - - api_key: [] - - {} - responses: - '200': - description: some response +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /v1.0 + +securityDefinitions: + oauth: + type: oauth2 + flow: password + tokenUrl: https://oauth.example/token + x-tokenInfoUrl: https://oauth.example/token_info + scopes: + myscope: can do stuff + otherscope: another scope + + api_key: + type: apiKey + name: X-Auth + in: header + x-apikeyInfoFunc: fakeapi.hello.apikey_info + + jwt: + type: apiKey + name: Authorization + in: header + x-authentication-scheme: Bearer + x-bearerInfoFunc: fakeapi.hello.jwt_info + + auth_exception: + type: apiKey + name: X-Api-Key + in: header + x-apikeyInfoFunc: fakeapi.hello.apikey_exception + +paths: + /byesecure/{name}: + get: + summary: Generate goodbye + description: Generates a goodbye message. + operationId: fakeapi.hello.get_bye_secure + security: + - oauth: + - myscope + produces: + - text/plain + responses: + 200: + description: goodbye response + schema: + type: string + parameters: + - name: name + in: path + description: Name of the person to say bye. + required: true + type: string + + /byesecure-from-flask: + get: + summary: Generate goodbye + description: "" + operationId: fakeapi.hello.get_bye_secure_from_flask + security: + - oauth: + - myscope + produces: + - text/plain + responses: + 200: + description: goodbye response + schema: + type: string + + /byesecure-from-firetail: + get: + summary: Generate goodbye + description: "" + operationId: fakeapi.hello.get_bye_secure_from_firetail + security: + - oauth: + - myscope + produces: + - text/plain + responses: + 200: + description: goodbye response + schema: + type: string + + /byesecure-ignoring-context/{name}: + get: + summary: Generate goodbye + description: Generates a goodbye message. + operationId: fakeapi.hello.get_bye_secure_ignoring_context + security: + - oauth: + - myscope + produces: + - text/plain + responses: + 200: + description: goodbye response + schema: + type: string + parameters: + - name: name + in: path + description: Name of the person to say bye. + required: true + type: string + + /byesecure-jwt/: + get: + summary: Generate goodbye + description: "" + operationId: fakeapi.hello.get_bye_secure_jwt + security: + - jwt: [] + produces: + - text/plain + responses: + 200: + description: goodbye response + schema: + type: string + parameters: + - name: name + in: path + description: Name of the person to say bye. + required: true + type: string + + /more-than-one-security-definition: + get: + summary: Some external call to API + description: External application call + operationId: fakeapi.hello.schema_list + security: + - oauth: + - myscope + - api_key: [] + responses: + 200: + description: OK + + /more-than-one-scope: + get: + summary: Test more than one scope + description: | + Test that firetail handles scopes properly by verifying that user has + all scopes necessary to call the endpoint. + operationId: fakeapi.hello.more_than_one_scope_defined + security: + - oauth: + - myscope + - otherscope + produces: + - text/plain + responses: + 200: + description: some response + + /optional-auth: + get: + summary: Test empty security definition + description: | + Test that firetail handles an empty security definition correctly. + In case an empty definition is provided, the user is required to + apply proper authentication and authorization techniques. + operationId: fakeapi.hello.optional_auth + security: + - api_key: [] + - {} + responses: + '200': + description: some response + + /auth-exception: + get: + summary: Test security handler function that raises an exception + description: Throw error from security function + operationId: fakeapi.hello.auth_exception + security: + - auth_exception: [] + responses: + '200': + description: some response diff --git a/tests/fixtures/simple/basepath-slash.yaml b/tests/fixtures/simple/basepath-slash.yaml index 0d0a2fd..eacb83f 100644 --- a/tests/fixtures/simple/basepath-slash.yaml +++ b/tests/fixtures/simple/basepath-slash.yaml @@ -1,25 +1,25 @@ -swagger: "2.0" - -info: - title: "Test basePath == /" - version: "1.0" - -basePath: / - -paths: - /greeting/{name}: - post: - summary: Generate greeting - description: Generates a greeting message. - operationId: fakeapi.hello.post_greeting - responses: - '200': - description: greeting response - schema: - type: object - parameters: - - name: name - in: path - description: Name of the person to greet. - required: true - type: string +swagger: "2.0" + +info: + title: "Test basePath == /" + version: "1.0" + +basePath: / + +paths: + /greeting/{name}: + post: + summary: Generate greeting + description: Generates a greeting message. + operationId: fakeapi.hello.post_greeting + responses: + '200': + description: greeting response + schema: + type: object + parameters: + - name: name + in: path + description: Name of the person to greet. + required: true + type: string diff --git a/tests/fixtures/simple/openapi.yaml b/tests/fixtures/simple/openapi.yaml index 5a79836..7acc8aa 100644 --- a/tests/fixtures/simple/openapi.yaml +++ b/tests/fixtures/simple/openapi.yaml @@ -1,1280 +1,1297 @@ -openapi: 3.0.0 -info: - title: '{{title}}' - version: '1.0' -paths: - '/greeting/{name}': - post: - summary: Generate greeting - description: Generates a greeting message. - operationId: fakeapi.hello.post_greeting - responses: - '200': - description: greeting response - content: - 'application/json': - schema: - type: object - parameters: - - name: name - in: path - description: Name of the person to greet. - required: true - schema: - type: string - '/greeting/{name}/{remainder}': - post: - summary: Generate greeting and collect the remainder of the url - description: Generates a greeting message and includes the rest of the url. - operationId: fakeapi.hello.post_greeting_url - responses: - '200': - description: greeting response with url - content: - 'application/json': - schema: - type: object - parameters: - - name: name - in: path - description: Name of the person to greet. - required: true - schema: - type: string - - name: remainder - in: path - description: the rest of the url - required: true - schema: - type: string - format: path - '/greetings/{name}': - get: - summary: Generate greeting - description: Generates a greeting message with custom mimetype - operationId: fakeapi.hello.get_greetings - responses: - '200': - description: greeting response - content: - application/x.firetail+json: - schema: - type: object - parameters: - - name: name - in: path - description: Name of the person to greet. - required: true - schema: - type: string - '/bye/{name}': - get: - summary: Generate goodbye - description: Generates a goodbye message. - operationId: fakeapi.hello.get_bye - responses: - '200': - description: goodbye response - content: - text/plain: - schema: - type: string - default: - description: unexpected error - parameters: - - name: name - in: path - description: Name of the person to say bye. - required: true - schema: - type: string - /flask_response_tuple: - get: - summary: Return flask response tuple - description: Test returning a flask response tuple - operationId: fakeapi.hello.get_flask_response_tuple - responses: - '200': - description: json response - content: - application/json: - schema: - type: object - '/list/{name}': - get: - summary: Generate a greeting in a list - description: Generate a greeting in a list - operationId: fakeapi.hello.get_list - responses: - '200': - description: a greeting in a list - content: - application/json: - schema: - type: array - items: - type: string - parameters: - - name: name - in: path - description: Name of the person to say hello to. - required: true - schema: - type: string - /test_no_content_response: - get: - operationId: fakeapi.hello.test_no_content_response - responses: - '204': - description: No content returned - /multimime: - get: - summary: Has multiple content types - description: Has multiple content types - operationId: fakeapi.hello.multimime - responses: - '200': - description: goodbye response - content: - application/json: - schema: - type: string - application/xml: - schema: - type: string - /empty: - get: - summary: Returns empty response - description: Returns empty response - operationId: fakeapi.hello.empty - responses: - '204': - description: empty - /exploded-deep-object-param: - get: - summary: Returns dict response - description: Returns dict response - operationId: fakeapi.hello.test_exploded_deep_object_param - parameters: - - name: id - required: true - in: query - style: deepObject - explode: true - schema: - type: object - additionalProperties: false - properties: - foo: - type: string - fooint: - type: integer - fooboo: - type: boolean - foo4: - type: string - default: blubb - responses: - '200': - description: object response - content: - application/json: - schema: - type: object - properties: - foo: - type: string - foo4: - type: string - /exploded-deep-object-param-additional-properties: - get: - summary: Returns dict response with flexible properties - description: Returns dict response with flexible properties - operationId: fakeapi.hello.test_exploded_deep_object_param_additional_properties - parameters: - - name: id - required: false - in: query - style: deepObject - explode: true - schema: - type: object - additionalProperties: - type: string - responses: - '200': - description: object response - content: - application/json: - schema: - type: object - additionalProperties: - type: string - /nested-exploded-deep-object-param: - get: - summary: Returns nested dict response - description: Returns nested dict response - operationId: fakeapi.hello.test_nested_exploded_deep_object_param - parameters: - - name: id - required: true - in: query - style: deepObject - explode: true - schema: - type: object - properties: - foo: - type: object - properties: - foo2: - type: string - foo3: - type: string - default: blubb - foofoo: - type: string - responses: - '200': - description: object response - content: - application/json: - schema: - type: object - properties: - foo: - type: object - properties: - foo2: - type: string - foo3: - type: string - /test-redirect-endpoint: - get: - summary: Tests handlers returning flask.Response objects - operationId: fakeapi.hello.test_redirect_endpoint - responses: - '302': - description: 302 Found - /test-redirect-response-endpoint: - get: - summary: Tests handlers returning flask.Response objects - operationId: fakeapi.hello.test_redirect_response_endpoint - responses: - '302': - description: 302 Found - /test-default-object-body: - post: - summary: Test if default object body param is passed to handler. - operationId: fakeapi.hello.test_default_object_body - responses: - '200': - description: OK - requestBody: - x-body-name: stack - content: - application/json: - schema: - # should be ignored because the preferred location is at the requestBody level above - x-body-name: this_should_be_ignored - $ref: '#/components/schemas/new_stack' - default: - image_version: default_image - /test-nested-additional-properties: - post: - summary: Test if nested additionalProperties are cast - operationId: fakeapi.hello.test_nested_additional_properties - responses: - '200': - description: OK - requestBody: - content: - application/json: - schema: - type: object - properties: - nested: - type: object - properties: {} - additionalProperties: - type: boolean - /test-default-integer-body: - post: - summary: Test if default integer body param is passed to handler. - operationId: fakeapi.hello.test_default_integer_body - responses: - '200': - description: OK - requestBody: - content: - application/json: - schema: - x-body-name: stack_version - type: integer - format: int32 - example: 1 - default: 1 - /test-empty-object-body: - post: - summary: Test if empty object body param is passed to handler. - operationId: fakeapi.hello.test_empty_object_body - responses: - '200': - description: OK - requestBody: - content: - application/json: - schema: - x-body-name: stack - type: object - /resolver-test/method: - get: - summary: Test class instance method - operationId: fakeapi.hello.class_instance.test_method - responses: - '200': - description: OK - /resolver-test/classmethod: - get: - summary: Test class instance method - operationId: fakeapi.hello.DummyClass.test_classmethod - responses: - '200': - description: OK - /test_parameter_validation: - get: - operationId: fakeapi.hello.test_parameter_validation - parameters: - - name: date - in: query - schema: - type: string - format: date - - name: int - in: query - schema: - type: integer - - name: bool - in: query - schema: - type: boolean - responses: - '200': - description: OK - /test_apikey_query_parameter_validation: - get: - operationId: fakeapi.hello.test_apikey_query_parameter_validation - parameters: - - name: name - in: query - schema: - type: string - security: - - api_key: [] - responses: - '200': - description: OK - /test_required_query_param: - get: - operationId: fakeapi.hello.test_required_query_param - parameters: - - name: 'n' - in: query - required: true - schema: - type: number - responses: - '200': - description: OK - - /test_array_csv_form_param: - post: - operationId: fakeapi.hello.test_array_csv_form_param3 - requestBody: - content: - application/x-www-form-urlencoded: - schema: - type: object - default: - items: ["squash", "banana"] - x-body-name: items - properties: - items: - type: array - items: - type: string - encoding: - items: - style: form - explode: false - responses: - 200: - description: OK - - /test_array_pipes_form_param: - post: - operationId: fakeapi.hello.test_array_pipes_form_param3 - requestBody: - content: - application/x-www-form-urlencoded: - schema: - type: object - x-body-name: items - properties: - items: - type: array - items: - type: integer - required: - - items - encoding: - items: - style: pipeDelimited - responses: - 200: - description: OK - - /test_array_csv_query_param: - get: - operationId: fakeapi.hello.test_array_csv_query_param - parameters: - - name: items - in: query - description: An comma separated array of items - style: form - explode: false - schema: - type: array - default: ["squash", "banana"] - items: - type: string - responses: - '200': - description: OK - - - /test_array_multi_query_param: - get: - operationId: fakeapi.hello.test_array_multi_query_param - parameters: - - name: items - in: query - description: An comma separated array of items - style: form - explode: true - schema: - type: array - default: ["squash", "banana"] - items: - type: string - responses: - '200': - description: OK - /test_array_pipes_query_param: - get: - operationId: fakeapi.hello.test_array_pipes_query_param - parameters: - - name: items - in: query - description: An pipe separated array of items - required: true - style: pipeDelimited - schema: - type: array - items: - type: integer - responses: - '200': - description: OK - /test_array_unsupported_query_param: - get: - operationId: fakeapi.hello.test_array_unsupported_query_param - parameters: - - name: items - in: query - description: An pipe separated array of items - required: true - style: pipeDelimited - schema: - type: array - items: - type: string - responses: - '200': - description: OK - '/test-int-path/{someint}': - get: - summary: Test type casting of path parameter - operationId: fakeapi.hello.test_get_someint - parameters: - - name: someint - in: path - required: true - schema: - type: integer - responses: - '200': - description: OK - '/test-float-path/{somefloat}': - get: - summary: Test type casting of path parameter - operationId: fakeapi.hello.test_get_somefloat - parameters: - - name: somefloat - in: path - required: true - schema: - type: number - responses: - '200': - description: OK - /test-default-query-parameter: - get: - summary: Test if default parameter is passed to function - operationId: fakeapi.hello.test_default_param - parameters: - - name: name - in: query - schema: - type: string - default: firetail - responses: - '200': - description: OK - /test-falsy-param: - get: - summary: Test if default value when argument is falsy. - operationId: fakeapi.hello.test_falsy_param - parameters: - - name: falsy - in: query - schema: - type: integer - default: 1 - responses: - '200': - description: OK - /test-formData-param: - post: - summary: Test formData parameter - operationId: fakeapi.hello.test_formdata_param3 - responses: - '200': - description: OK - requestBody: - content: - application/x-www-form-urlencoded: - schema: - type: object - properties: - formData: - type: string - required: - - formData - /test-formData-missing-param: - post: - summary: Test formData missing parameter in handler - operationId: fakeapi.hello.test_formdata_missing_param - responses: - '200': - description: OK - requestBody: - content: - application/x-www-form-urlencoded: - schema: - type: object - properties: - missing_formData: - type: string - required: - - missing_formData - /test-formData-file-upload: - post: - summary: 'Test formData with file type, for file upload' - operationId: fakeapi.hello.test_formdata_file_upload - responses: - '200': - description: OK - requestBody: - content: - multipart/form-data: - schema: - x-body-name: formData - type: object - properties: - formData: - type: string - format: binary - required: - - formData - /test-formData-file-upload-missing-param: - post: - summary: 'Test formData with file type, missing parameter in handler' - operationId: fakeapi.hello.test_formdata_file_upload_missing_param - responses: - '200': - description: OK - requestBody: - content: - multipart/form-data: - schema: - type: object - properties: - missing_formData: - type: string - format: binary - required: - - missing_formData - /test-bool-param: - get: - summary: Test usage of boolean default value - operationId: fakeapi.hello.test_bool_default_param - parameters: - - name: thruthiness - in: query - schema: - type: boolean - default: false - responses: - '200': - description: OK - /test-bool-array-param: - get: - summary: Test usage of an array of booleans value - operationId: fakeapi.hello.test_bool_array_param - parameters: - - name: thruthiness - in: query - schema: - type: array - items: - type: boolean - responses: - '200': - description: OK - /test-required-param: - get: - summary: Test required param without default value - operationId: fakeapi.hello.test_required_param - parameters: - - name: simple - in: query - required: true - schema: - type: string - responses: - '200': - description: OK - /test-cookie-param: - get: - summary: Test cookie parameter support. - operationId: fakeapi.hello.test_cookie_param - parameters: - - name: test_cookie - in: cookie - required: true - schema: - type: string - responses: - '200': - description: OK - /parameters-in-root-path: - parameters: - - in: query - name: title - description: Some parameter in the path - required: true - schema: - type: string - get: - summary: Test the method GET with parameter from path - operationId: fakeapi.hello.path_parameters_in_get_method - responses: - '200': - description: OK - '/goodday/{name}': - post: - summary: Generate good day greeting - description: Generates a good day message. - operationId: fakeapi.hello.post_goodday - responses: - '201': - description: gooday response - headers: - Location: - description: The URI of the created resource - schema: - type: string - required: true - content: - 'application/json': - schema: - type: object - parameters: - - name: name - in: path - description: Name of the person to greet. - required: true - schema: - type: string - /goodday/noheader: - post: - summary: Generate good day greeting - description: Generates a good day message. - operationId: fakeapi.hello.post_goodday_no_header - responses: - '201': - description: goodday response - headers: - Location: - description: The URI of the created resource - schema: - type: string - required: true - content: - 'application/json': - schema: - type: object - '/goodevening/{name}': - post: - summary: Generate good evening - description: Generates a good evening message. - operationId: fakeapi.hello.post_goodevening - responses: - '201': - description: goodevening response - headers: - Location: - description: The URI of the created resource - schema: - type: string - required: true - content: - text/plain: - schema: - type: string - examples: - justAnExample: - $ref: '#/components/examples/justAnExample' - parameters: - - name: name - in: path - description: Name of the person to say good evening. - required: true - schema: - type: string - /test-204-with-headers: - get: - summary: Tests that response code 204 can have headers set - operationId: fakeapi.hello.test_204_with_headers - responses: - '204': - headers: - X-Something: - description: A value that might be send in the response - schema: - type: string - description: 204 no content - /test-204-with-headers-nocontent-obj: - get: - summary: Tests that response code 204 using NoContent obj can have headers set - operationId: fakeapi.hello.test_nocontent_obj_with_headers - responses: - '204': - headers: - X-Something: - description: A value that might be send in the response - schema: - type: string - description: 204 no content - '/test-array-in-path/{names}': - get: - operationId: fakeapi.hello.test_array_in_path - parameters: - - name: names - description: List of names. - in: path - required: true - style: simple - schema: - type: array - items: - type: string - responses: - '200': - description: OK - content: - application/json: - schema: - type: array - items: - type: string - /nullable-parameters: - post: - operationId: fakeapi.hello.test_nullable_param_post3 - responses: - '200': - description: OK - requestBody: - content: - application/x-www-form-urlencoded: - schema: - type: object - nullable: true - properties: - post_param: - description: Just a testing parameter. - type: number - format: int32 - nullable: true - required: - - post_param - put: - operationId: fakeapi.hello.test_nullable_param_put - responses: - '200': - description: OK - requestBody: - content: - application/json: - schema: - nullable: true - x-body-name: contents - type: object - properties: - name: - type: string - description: Just a testing parameter. - required: true - get: - operationId: fakeapi.hello.test_nullable_parameters - parameters: - - name: time_start - description: Just a testing parameter. - in: query - required: true - schema: - nullable: true - type: integer - format: int32 - responses: - '200': - description: OK - /nullable-parameters-noargs: - put: - operationId: fakeapi.hello.test_nullable_param_put_noargs - responses: - '200': - description: OK - requestBody: - content: - application/json: - schema: - nullable: true - x-body-name: contents - type: object - properties: - name: - type: string - description: Just a testing parameter. - required: true - /custom-json-response: - get: - operationId: fakeapi.hello.test_custom_json_response - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - theResult: - type: string - description: the number we wanna test - /blob-response: - get: - operationId: fakeapi.hello.get_blob_data - responses: - '200': - description: Some blob response - content: - application/octet-stream: - schema: - type: string - format: binary - /binary-response: - get: - operationId: fakeapi.hello.get_data_as_binary - responses: - '200': - description: Everything is ok - content: - application/octet-stream: - schema: - type: string - /query-params-as-kwargs: - get: - operationId: fakeapi.hello.test_args_kwargs - parameters: - - name: foo - description: Just a testing parameter. - in: query - schema: - type: string - responses: - '200': - description: Return kwargs - content: - application/json: - schema: - type: object - /body-params-as-kwargs: - post: - operationId: fakeapi.hello.test_args_kwargs_post - requestBody: - content: - application/json: - schema: - type: object - properties: - foo: - type: string - additionalProperties: true - responses: - '200': - description: Return kwargs - content: - application/json: - schema: - type: object - /text-request: - post: - operationId: fakeapi.hello.get_data_as_text - responses: - '200': - description: OK - requestBody: - content: - text/plain: - schema: - x-body-name: post_param - type: string - description: Just a testing parameter. - required: true - /param-sanitization: - post: - operationId: fakeapi.hello.test_param_sanitization3 - parameters: - - name: $query - description: Just a testing parameter with an invalid Python name - in: query - schema: - type: string - responses: - '200': - description: Return parameters - content: - application/json: - schema: - type: object - requestBody: - content: - multipart/form-data: - schema: - type: object - properties: - '$form': - description: Just a testing parameter in the form data - type: string - /body-sanitization: - post: - operationId: fakeapi.hello.test_body_sanitization - responses: - '200': - description: OK - requestBody: - $ref: '#/components/requestBodies/fakeapi.hello.test_body_sanitization_body' - /body-sanitization-additional-properties: - post: - operationId: fakeapi.hello.test_body_sanitization_additional_properties - responses: - '200': - description: OK - requestBody: - content: - application/json: - schema: - type: object - properties: - body1: - type: string - additionalProperties: true - /body-sanitization-additional-properties-defined: - post: - operationId: fakeapi.hello.test_body_sanitization_additional_properties_defined - responses: - '200': - description: OK - requestBody: - content: - application/json: - schema: - type: object - properties: - body1: - type: string - additionalProperties: - type: string - /body-not-allowed-additional-properties: - post: - operationId: fakeapi.hello.test_body_not_allowed_additional_properties - responses: - '200': - description: OK - requestBody: - content: - application/json: - schema: - type: object - properties: - body1: - type: string - additionalProperties: false - /get_non_conforming_response: - get: - operationId: fakeapi.hello.get_empty_dict - responses: - '200': - description: OK - content: - 'application/json': - schema: - type: object - required: - - some - properties: - some: - type: string - /post_wrong_content_type: - post: - operationId: fakeapi.hello.post_wrong_content_type - responses: - '200': - description: OK - '215': - description: NOT-OK - requestBody: - $ref: '#/components/requestBodies/fakeapi.hello.test_body_sanitization_body' - /get_unicode_request: - get: - summary: Test if a unicode string in query parameter works properly in Python 2 - operationId: fakeapi.hello.get_unicode_query - parameters: - - name: price - in: query - schema: - type: string - responses: - '200': - description: OK - /get_unicode_response: - get: - operationId: fakeapi.hello.get_unicode_data - responses: - '200': - description: Some unicode response - content: - application/json: - schema: - type: object - /get_enum_response: - get: - operationId: fakeapi.hello.get_enum_response - responses: - '200': - description: Some HTTPStatus response - content: - application/json: - schema: - type: object - /get_httpstatus_response: - get: - operationId: fakeapi.hello.get_httpstatus_response - responses: - '200': - description: Some HTTPStatus response - content: - application/json: - schema: - type: object - '/get_bad_default_response/{response_code}': - get: - operationId: fakeapi.hello.get_bad_default_response - parameters: - - name: response_code - in: path - required: true - schema: - type: integer - responses: - '200': - description: Some object response - content: - application/json: - schema: - type: object - default: - description: Some array response - content: - application/json: - schema: - type: array - items: {} - /add_operation_on_http_methods_only: - summary: this is a test - description: check if add_operation is called only on http methods field - x-test: True - servers: - - url: http://localhost - parameters: - - $ref: "#/components/parameters/Name" - get: - operationId: fakeapi.hello.get_add_operation_on_http_methods_only - responses: - default: - description: '' - put: - operationId: fakeapi.hello.put_add_operation_on_http_methods_only - responses: - default: - description: '' - post: - operationId: fakeapi.hello.post_add_operation_on_http_methods_only - responses: - default: - description: '' - delete: - operationId: fakeapi.hello.delete_add_operation_on_http_methods_only - responses: - default: - description: '' - options: - operationId: fakeapi.hello.options_add_operation_on_http_methods_only - responses: - default: - description: '' - head: - operationId: fakeapi.hello.head_add_operation_on_http_methods_only - responses: - default: - description: '' - patch: - operationId: fakeapi.hello.patch_add_operation_on_http_methods_only - responses: - default: - description: '' - trace: - operationId: fakeapi.hello.trace_add_operation_on_http_methods_only - responses: - default: - description: '' - /forward: - post: - operationId: fakeapi.hello.forward - requestBody: - content: - application/json: - schema: - type: object - responses: - '200': - description: > - The response containing the same data as were present in request body. - content: - application/json: - schema: - type: object - /test-optional-headers: - get: - operationId: fakeapi.hello.test_optional_headers - responses: - '200': - description: Some object response - content: - application/json: - schema: - type: object - headers: - X-Optional-Header: - description: Optional header - schema: - type: string - required: false - /get_streaming_response: - get: - operationId: fakeapi.hello.get_streaming_response - responses: - '200': - description: OK - content: - application/octet-stream: - schema: - type: string - format: binary - -servers: - - url: http://localhost:{port}/{basePath} - variables: - port: - enum: - - '4000' - - '5000' - default: '5000' - basePath: - enum: - - v1.0 - - v1 - default: v1.0 - - url: /v1.0 -components: - requestBodies: - fakeapi.hello.test_body_sanitization_body: - content: - application/json: - schema: - type: object - properties: - body1: - type: string - body2: - type: string - description: Just a testing parameter in the body - required: true - examples: - justAnExample: - summary: a basic example. - value: Good evening, doctor. - schemas: - new_stack: - type: object - properties: - image_version: - type: string - description: Docker image version to deploy - required: - - image_version - parameters: - Name: - name: name - in: path - description: Name of the person to greet. - required: true - schema: - type: string - securitySchemes: - api_key: - type: apiKey - name: apikey - in: query - x-apikeyInfoFunc: fakeapi.hello.apikey_info +openapi: 3.0.0 +info: + title: '{{title}}' + version: '1.0' +paths: + '/greeting/{name}': + post: + summary: Generate greeting + description: Generates a greeting message. + operationId: fakeapi.hello.post_greeting + responses: + '200': + description: greeting response + content: + 'application/json': + schema: + type: object + parameters: + - name: name + in: path + description: Name of the person to greet. + required: true + schema: + type: string + '/greeting/{name}/{remainder}': + post: + summary: Generate greeting and collect the remainder of the url + description: Generates a greeting message and includes the rest of the url. + operationId: fakeapi.hello.post_greeting_url + responses: + '200': + description: greeting response with url + content: + 'application/json': + schema: + type: object + parameters: + - name: name + in: path + description: Name of the person to greet. + required: true + schema: + type: string + - name: remainder + in: path + description: the rest of the url + required: true + schema: + type: string + format: path + '/greetings/{name}': + get: + summary: Generate greeting + description: Generates a greeting message with custom mimetype + operationId: fakeapi.hello.get_greetings + responses: + '200': + description: greeting response + content: + application/x.firetail+json: + schema: + type: object + parameters: + - name: name + in: path + description: Name of the person to greet. + required: true + schema: + type: string + '/bye/{name}': + get: + summary: Generate goodbye + description: Generates a goodbye message. + operationId: fakeapi.hello.get_bye + responses: + '200': + description: goodbye response + content: + text/plain: + schema: + type: string + default: + description: unexpected error + parameters: + - name: name + in: path + description: Name of the person to say bye. + required: true + schema: + type: string + /flask_response_tuple: + get: + summary: Return flask response tuple + description: Test returning a flask response tuple + operationId: fakeapi.hello.get_flask_response_tuple + responses: + '200': + description: json response + content: + application/json: + schema: + type: object + '/list/{name}': + get: + summary: Generate a greeting in a list + description: Generate a greeting in a list + operationId: fakeapi.hello.get_list + responses: + '200': + description: a greeting in a list + content: + application/json: + schema: + type: array + items: + type: string + parameters: + - name: name + in: path + description: Name of the person to say hello to. + required: true + schema: + type: string + /test_no_content_response: + get: + operationId: fakeapi.hello.test_no_content_response + responses: + '204': + description: No content returned + /multimime: + get: + summary: Has multiple content types + description: Has multiple content types + operationId: fakeapi.hello.multimime + responses: + '200': + description: goodbye response + content: + application/json: + schema: + type: string + application/xml: + schema: + type: string + /empty: + get: + summary: Returns empty response + description: Returns empty response + operationId: fakeapi.hello.empty + responses: + '204': + description: empty + /exploded-deep-object-param: + get: + summary: Returns dict response + description: Returns dict response + operationId: fakeapi.hello.test_exploded_deep_object_param + parameters: + - name: id + required: true + in: query + style: deepObject + explode: true + schema: + type: object + additionalProperties: false + properties: + foo: + type: string + fooint: + type: integer + fooboo: + type: boolean + foo4: + type: string + default: blubb + responses: + '200': + description: object response + content: + application/json: + schema: + type: object + properties: + foo: + type: string + foo4: + type: string + /exploded-deep-object-param-additional-properties: + get: + summary: Returns dict response with flexible properties + description: Returns dict response with flexible properties + operationId: fakeapi.hello.test_exploded_deep_object_param_additional_properties + parameters: + - name: id + required: false + in: query + style: deepObject + explode: true + schema: + type: object + additionalProperties: + type: string + responses: + '200': + description: object response + content: + application/json: + schema: + type: object + additionalProperties: + type: string + /nested-exploded-deep-object-param: + get: + summary: Returns nested dict response + description: Returns nested dict response + operationId: fakeapi.hello.test_nested_exploded_deep_object_param + parameters: + - name: id + required: true + in: query + style: deepObject + explode: true + schema: + type: object + properties: + foo: + type: object + properties: + foo2: + type: string + foo3: + type: string + default: blubb + foofoo: + type: string + responses: + '200': + description: object response + content: + application/json: + schema: + type: object + properties: + foo: + type: object + properties: + foo2: + type: string + foo3: + type: string + /test-redirect-endpoint: + get: + summary: Tests handlers returning flask.Response objects + operationId: fakeapi.hello.test_redirect_endpoint + responses: + '302': + description: 302 Found + /test-redirect-response-endpoint: + get: + summary: Tests handlers returning flask.Response objects + operationId: fakeapi.hello.test_redirect_response_endpoint + responses: + '302': + description: 302 Found + /test-default-object-body: + post: + summary: Test if default object body param is passed to handler. + operationId: fakeapi.hello.test_default_object_body + responses: + '200': + description: OK + requestBody: + x-body-name: stack + content: + application/json: + schema: + # should be ignored because the preferred location is at the requestBody level above + x-body-name: this_should_be_ignored + $ref: '#/components/schemas/new_stack' + default: + image_version: default_image + /test-nested-additional-properties: + post: + summary: Test if nested additionalProperties are cast + operationId: fakeapi.hello.test_nested_additional_properties + responses: + '200': + description: OK + requestBody: + content: + application/json: + schema: + type: object + properties: + nested: + type: object + properties: {} + additionalProperties: + type: boolean + /test-default-integer-body: + post: + summary: Test if default integer body param is passed to handler. + operationId: fakeapi.hello.test_default_integer_body + responses: + '200': + description: OK + requestBody: + content: + application/json: + schema: + x-body-name: stack_version + type: integer + format: int32 + example: 1 + default: 1 + /test-empty-object-body: + post: + summary: Test if empty object body param is passed to handler. + operationId: fakeapi.hello.test_empty_object_body + responses: + '200': + description: OK + requestBody: + content: + application/json: + schema: + x-body-name: stack + type: object + /resolver-test/method: + get: + summary: Test class instance method + operationId: fakeapi.hello.class_instance.test_method + responses: + '200': + description: OK + /resolver-test/classmethod: + get: + summary: Test class instance method + operationId: fakeapi.hello.DummyClass.test_classmethod + responses: + '200': + description: OK + /test_parameter_validation: + get: + operationId: fakeapi.hello.test_parameter_validation + parameters: + - name: date + in: query + schema: + type: string + format: date + - name: int + in: query + schema: + type: integer + - name: bool + in: query + schema: + type: boolean + responses: + '200': + description: OK + /test_apikey_query_parameter_validation: + get: + operationId: fakeapi.hello.test_apikey_query_parameter_validation + parameters: + - name: name + in: query + schema: + type: string + security: + - api_key: [] + responses: + '200': + description: OK + /test_required_query_param: + get: + operationId: fakeapi.hello.test_required_query_param + parameters: + - name: 'n' + in: query + required: true + schema: + type: number + responses: + '200': + description: OK + + /test_array_csv_form_param: + post: + operationId: fakeapi.hello.test_array_csv_form_param3 + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + default: + items: ["squash", "banana"] + x-body-name: items + properties: + items: + type: array + items: + type: string + encoding: + items: + style: form + explode: false + responses: + 200: + description: OK + + /test_array_pipes_form_param: + post: + operationId: fakeapi.hello.test_array_pipes_form_param3 + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + x-body-name: items + properties: + items: + type: array + items: + type: integer + required: + - items + encoding: + items: + style: pipeDelimited + responses: + 200: + description: OK + + /test_array_csv_query_param: + get: + operationId: fakeapi.hello.test_array_csv_query_param + parameters: + - name: items + in: query + description: An comma separated array of items + style: form + explode: false + schema: + type: array + default: ["squash", "banana"] + items: + type: string + responses: + '200': + description: OK + + + /test_array_multi_query_param: + get: + operationId: fakeapi.hello.test_array_multi_query_param + parameters: + - name: items + in: query + description: An comma separated array of items + style: form + explode: true + schema: + type: array + default: ["squash", "banana"] + items: + type: string + responses: + '200': + description: OK + /test_array_pipes_query_param: + get: + operationId: fakeapi.hello.test_array_pipes_query_param + parameters: + - name: items + in: query + description: An pipe separated array of items + required: true + style: pipeDelimited + schema: + type: array + items: + type: integer + responses: + '200': + description: OK + /test_array_unsupported_query_param: + get: + operationId: fakeapi.hello.test_array_unsupported_query_param + parameters: + - name: items + in: query + description: An pipe separated array of items + required: true + style: pipeDelimited + schema: + type: array + items: + type: string + responses: + '200': + description: OK + '/test-int-path/{someint}': + get: + summary: Test type casting of path parameter + operationId: fakeapi.hello.test_get_someint + parameters: + - name: someint + in: path + required: true + schema: + type: integer + responses: + '200': + description: OK + '/test-float-path/{somefloat}': + get: + summary: Test type casting of path parameter + operationId: fakeapi.hello.test_get_somefloat + parameters: + - name: somefloat + in: path + required: true + schema: + type: number + responses: + '200': + description: OK + /test-default-query-parameter: + get: + summary: Test if default parameter is passed to function + operationId: fakeapi.hello.test_default_param + parameters: + - name: name + in: query + schema: + type: string + default: firetail + responses: + '200': + description: OK + /test-falsy-param: + get: + summary: Test if default value when argument is falsy. + operationId: fakeapi.hello.test_falsy_param + parameters: + - name: falsy + in: query + schema: + type: integer + default: 1 + responses: + '200': + description: OK + /test-formData-param: + post: + summary: Test formData parameter + operationId: fakeapi.hello.test_formdata_param3 + responses: + '200': + description: OK + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + formData: + type: string + required: + - formData + /test-formData-missing-param: + post: + summary: Test formData missing parameter in handler + operationId: fakeapi.hello.test_formdata_missing_param + responses: + '200': + description: OK + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + missing_formData: + type: string + required: + - missing_formData + /test-formData-file-upload: + post: + summary: 'Test formData with file type, for file upload' + operationId: fakeapi.hello.test_formdata_file_upload + responses: + '200': + description: OK + requestBody: + content: + multipart/form-data: + schema: + x-body-name: formData + type: object + properties: + formData: + type: string + format: binary + required: + - formData + /test-formData-file-upload-missing-param: + post: + summary: 'Test formData with file type, missing parameter in handler' + operationId: fakeapi.hello.test_formdata_file_upload_missing_param + responses: + '200': + description: OK + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + missing_formData: + type: string + format: binary + required: + - missing_formData + /test-bool-param: + get: + summary: Test usage of boolean default value + operationId: fakeapi.hello.test_bool_default_param + parameters: + - name: thruthiness + in: query + schema: + type: boolean + default: false + responses: + '200': + description: OK + /test-bool-array-param: + get: + summary: Test usage of an array of booleans value + operationId: fakeapi.hello.test_bool_array_param + parameters: + - name: thruthiness + in: query + schema: + type: array + items: + type: boolean + responses: + '200': + description: OK + /test-required-param: + get: + summary: Test required param without default value + operationId: fakeapi.hello.test_required_param + parameters: + - name: simple + in: query + required: true + schema: + type: string + responses: + '200': + description: OK + /test-cookie-param: + get: + summary: Test cookie parameter support. + operationId: fakeapi.hello.test_cookie_param + parameters: + - name: test_cookie + in: cookie + required: true + schema: + type: string + responses: + '200': + description: OK + /parameters-in-root-path: + parameters: + - in: query + name: title + description: Some parameter in the path + required: true + schema: + type: string + get: + summary: Test the method GET with parameter from path + operationId: fakeapi.hello.path_parameters_in_get_method + responses: + '200': + description: OK + '/goodday/{name}': + post: + summary: Generate good day greeting + description: Generates a good day message. + operationId: fakeapi.hello.post_goodday + responses: + '201': + description: gooday response + headers: + Location: + description: The URI of the created resource + schema: + type: string + required: true + content: + 'application/json': + schema: + type: object + parameters: + - name: name + in: path + description: Name of the person to greet. + required: true + schema: + type: string + /goodday/noheader: + post: + summary: Generate good day greeting + description: Generates a good day message. + operationId: fakeapi.hello.post_goodday_no_header + responses: + '201': + description: goodday response + headers: + Location: + description: The URI of the created resource + schema: + type: string + required: true + content: + 'application/json': + schema: + type: object + '/goodevening/{name}': + post: + summary: Generate good evening + description: Generates a good evening message. + operationId: fakeapi.hello.post_goodevening + responses: + '201': + description: goodevening response + headers: + Location: + description: The URI of the created resource + schema: + type: string + required: true + content: + text/plain: + schema: + type: string + examples: + justAnExample: + $ref: '#/components/examples/justAnExample' + parameters: + - name: name + in: path + description: Name of the person to say good evening. + required: true + schema: + type: string + /test-204-with-headers: + get: + summary: Tests that response code 204 can have headers set + operationId: fakeapi.hello.test_204_with_headers + responses: + '204': + headers: + X-Something: + description: A value that might be send in the response + schema: + type: string + description: 204 no content + /test-204-with-headers-nocontent-obj: + get: + summary: Tests that response code 204 using NoContent obj can have headers set + operationId: fakeapi.hello.test_nocontent_obj_with_headers + responses: + '204': + headers: + X-Something: + description: A value that might be send in the response + schema: + type: string + description: 204 no content + '/test-array-in-path/{names}': + get: + operationId: fakeapi.hello.test_array_in_path + parameters: + - name: names + description: List of names. + in: path + required: true + style: simple + schema: + type: array + items: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + type: string + /nullable-parameters: + post: + operationId: fakeapi.hello.test_nullable_param_post3 + responses: + '200': + description: OK + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + nullable: true + properties: + post_param: + description: Just a testing parameter. + type: number + format: int32 + nullable: true + required: + - post_param + put: + operationId: fakeapi.hello.test_nullable_param_put + responses: + '200': + description: OK + requestBody: + content: + application/json: + schema: + nullable: true + x-body-name: contents + type: object + properties: + name: + type: string + description: Just a testing parameter. + required: true + get: + operationId: fakeapi.hello.test_nullable_parameters + parameters: + - name: time_start + description: Just a testing parameter. + in: query + required: true + schema: + nullable: true + type: integer + format: int32 + responses: + '200': + description: OK + /nullable-parameters-noargs: + put: + operationId: fakeapi.hello.test_nullable_param_put_noargs + responses: + '200': + description: OK + requestBody: + content: + application/json: + schema: + nullable: true + x-body-name: contents + type: object + properties: + name: + type: string + description: Just a testing parameter. + required: true + /custom-json-response: + get: + operationId: fakeapi.hello.test_custom_json_response + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + theResult: + type: string + description: the number we wanna test + /blob-response: + get: + operationId: fakeapi.hello.get_blob_data + responses: + '200': + description: Some blob response + content: + application/octet-stream: + schema: + type: string + format: binary + /binary-response: + get: + operationId: fakeapi.hello.get_data_as_binary + responses: + '200': + description: Everything is ok + content: + application/octet-stream: + schema: + type: string + /query-params-as-kwargs: + get: + operationId: fakeapi.hello.test_args_kwargs + parameters: + - name: foo + description: Just a testing parameter. + in: query + schema: + type: string + responses: + '200': + description: Return kwargs + content: + application/json: + schema: + type: object + /body-params-as-kwargs: + post: + operationId: fakeapi.hello.test_args_kwargs_post + requestBody: + content: + application/json: + schema: + type: object + properties: + foo: + type: string + additionalProperties: true + responses: + '200': + description: Return kwargs + content: + application/json: + schema: + type: object + /text-request: + post: + operationId: fakeapi.hello.get_data_as_text + responses: + '200': + description: OK + requestBody: + content: + text/plain: + schema: + x-body-name: post_param + type: string + description: Just a testing parameter. + required: true + /param-sanitization: + post: + operationId: fakeapi.hello.test_param_sanitization3 + parameters: + - name: $query + description: Just a testing parameter with an invalid Python name + in: query + schema: + type: string + responses: + '200': + description: Return parameters + content: + application/json: + schema: + type: object + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + '$form': + description: Just a testing parameter in the form data + type: string + /body-sanitization: + post: + operationId: fakeapi.hello.test_body_sanitization + responses: + '200': + description: OK + requestBody: + $ref: '#/components/requestBodies/fakeapi.hello.test_body_sanitization_body' + /body-sanitization-additional-properties: + post: + operationId: fakeapi.hello.test_body_sanitization_additional_properties + responses: + '200': + description: OK + requestBody: + content: + application/json: + schema: + type: object + properties: + body1: + type: string + additionalProperties: true + /body-sanitization-additional-properties-defined: + post: + operationId: fakeapi.hello.test_body_sanitization_additional_properties_defined + responses: + '200': + description: OK + requestBody: + content: + application/json: + schema: + type: object + properties: + body1: + type: string + additionalProperties: + type: string + /body-not-allowed-additional-properties: + post: + operationId: fakeapi.hello.test_body_not_allowed_additional_properties + responses: + '200': + description: OK + requestBody: + content: + application/json: + schema: + type: object + properties: + body1: + type: string + additionalProperties: false + /get_non_conforming_response: + get: + operationId: fakeapi.hello.get_empty_dict + responses: + '200': + description: OK + content: + 'application/json': + schema: + type: object + required: + - some + properties: + some: + type: string + /post_wrong_content_type: + post: + operationId: fakeapi.hello.post_wrong_content_type + responses: + '200': + description: OK + '215': + description: NOT-OK + requestBody: + $ref: '#/components/requestBodies/fakeapi.hello.test_body_sanitization_body' + /get_unicode_request: + get: + summary: Test if a unicode string in query parameter works properly in Python 2 + operationId: fakeapi.hello.get_unicode_query + parameters: + - name: price + in: query + schema: + type: string + responses: + '200': + description: OK + /get_unicode_response: + get: + operationId: fakeapi.hello.get_unicode_data + responses: + '200': + description: Some unicode response + content: + application/json: + schema: + type: object + /get_enum_response: + get: + operationId: fakeapi.hello.get_enum_response + responses: + '200': + description: Some HTTPStatus response + content: + application/json: + schema: + type: object + /get_httpstatus_response: + get: + operationId: fakeapi.hello.get_httpstatus_response + responses: + '200': + description: Some HTTPStatus response + content: + application/json: + schema: + type: object + '/get_bad_default_response/{response_code}': + get: + operationId: fakeapi.hello.get_bad_default_response + parameters: + - name: response_code + in: path + required: true + schema: + type: integer + responses: + '200': + description: Some object response + content: + application/json: + schema: + type: object + default: + description: Some array response + content: + application/json: + schema: + type: array + items: {} + /add_operation_on_http_methods_only: + summary: this is a test + description: check if add_operation is called only on http methods field + x-test: True + servers: + - url: http://localhost + parameters: + - $ref: "#/components/parameters/Name" + get: + operationId: fakeapi.hello.get_add_operation_on_http_methods_only + responses: + default: + description: '' + put: + operationId: fakeapi.hello.put_add_operation_on_http_methods_only + responses: + default: + description: '' + post: + operationId: fakeapi.hello.post_add_operation_on_http_methods_only + responses: + default: + description: '' + delete: + operationId: fakeapi.hello.delete_add_operation_on_http_methods_only + responses: + default: + description: '' + options: + operationId: fakeapi.hello.options_add_operation_on_http_methods_only + responses: + default: + description: '' + head: + operationId: fakeapi.hello.head_add_operation_on_http_methods_only + responses: + default: + description: '' + patch: + operationId: fakeapi.hello.patch_add_operation_on_http_methods_only + responses: + default: + description: '' + trace: + operationId: fakeapi.hello.trace_add_operation_on_http_methods_only + responses: + default: + description: '' + /forward: + post: + operationId: fakeapi.hello.forward + requestBody: + content: + application/json: + schema: + type: object + responses: + '200': + description: > + The response containing the same data as were present in request body. + content: + application/json: + schema: + type: object + /test-optional-headers: + get: + operationId: fakeapi.hello.test_optional_headers + responses: + '200': + description: Some object response + content: + application/json: + schema: + type: object + headers: + X-Optional-Header: + description: Optional header + schema: + type: string + required: false + /get_streaming_response: + get: + operationId: fakeapi.hello.get_streaming_response + responses: + '200': + description: OK + content: + application/octet-stream: + schema: + type: string + format: binary + /oneof_greeting: + post: + operationId: fakeapi.hello.post_greeting3 + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + oneOf: + - {type: boolean} + - {type: number} + additionalProperties: false + responses: + '200': + description: Echo the validated request. + +servers: + - url: http://localhost:{port}/{basePath} + variables: + port: + enum: + - '4000' + - '5000' + default: '5000' + basePath: + enum: + - v1.0 + - v1 + default: v1.0 + - url: /v1.0 +components: + requestBodies: + fakeapi.hello.test_body_sanitization_body: + content: + application/json: + schema: + type: object + properties: + body1: + type: string + body2: + type: string + description: Just a testing parameter in the body + required: true + examples: + justAnExample: + summary: a basic example. + value: Good evening, doctor. + schemas: + new_stack: + type: object + properties: + image_version: + type: string + description: Docker image version to deploy + required: + - image_version + parameters: + Name: + name: name + in: path + description: Name of the person to greet. + required: true + schema: + type: string + securitySchemes: + api_key: + type: apiKey + name: apikey + in: query + x-apikeyInfoFunc: fakeapi.hello.apikey_info diff --git a/tests/fixtures/simple/swagger.yaml b/tests/fixtures/simple/swagger.yaml index efe89fc..5437bad 100644 --- a/tests/fixtures/simple/swagger.yaml +++ b/tests/fixtures/simple/swagger.yaml @@ -1,1062 +1,1062 @@ -swagger: "2.0" - -info: - title: "{{title}}" - version: "1.0" - -basePath: /v1.0 - -paths: - /greeting/{name}: - post: - summary: Generate greeting - description: Generates a greeting message. - operationId: fakeapi.hello.post_greeting - responses: - '200': - description: greeting response - schema: - type: object - parameters: - - name: name - in: path - description: Name of the person to greet. - required: true - type: string - /greeting/{name}/{remainder}: - post: - summary: Generate greeting and collect the remainder of the url - description: Generates a greeting message and includes the rest of the url. - operationId: fakeapi.hello.post_greeting_url - responses: - '200': - description: greeting response with url - schema: - type: object - parameters: - - name: name - in: path - description: Name of the person to greet. - required: true - type: string - - name: remainder - in: path - description: the rest of the url - required: true - type: string - format: path - /greetings/{name}: - get: - summary: Generate greeting - description: Generates a greeting message with custom mimetype - operationId: fakeapi.hello.get_greetings - produces: - - application/x.firetail+json - responses: - '200': - description: greeting response - schema: - type: object - parameters: - - name: name - in: path - description: Name of the person to greet. - required: true - type: string - - /bye/{name}: - get: - summary: Generate goodbye - description: Generates a goodbye message. - operationId: fakeapi.hello.get_bye - produces: - - text/plain - responses: - '200': - description: goodbye response - schema: - type: string - default: - description: "unexpected error" - parameters: - - name: name - in: path - description: Name of the person to say bye. - required: true - type: string - - /flask_response_tuple: - get: - summary: Return flask response tuple - description: Test returning a flask response tuple - operationId: fakeapi.hello.get_flask_response_tuple - produces: - - application/json - responses: - 200: - description: json response - schema: - type: object - - /list/{name}: - get: - summary: Generate a greeting in a list - description: Generate a greeting in a list - operationId: fakeapi.hello.get_list - produces: - - application/json - responses: - 200: - description: a greeting in a list - schema: - type: array - items: - type: string - parameters: - - name: name - in: path - description: Name of the person to say hello to. - required: true - type: string - - /test_no_content_response: - get: - operationId: fakeapi.hello.test_no_content_response - responses: - 204: - description: No content returned - - /multimime: - get: - summary: Has multiple content types - description: Has multiple content types - operationId: fakeapi.hello.multimime - produces: - - application/json - - application/xml - responses: - 200: - description: goodbye response - schema: - type: string - - /empty: - get: - summary: Returns empty response - description: Returns empty response - operationId: fakeapi.hello.empty - produces: - - application/json - responses: - 204: - description: empty - - /test-redirect-endpoint: - get: - summary: Tests handlers returning flask.Response objects - operationId: fakeapi.hello.test_redirect_endpoint - responses: - 302: - description: 302 Found - - /test-redirect-response-endpoint: - get: - summary: Tests handlers returning flask.Response objects - operationId: fakeapi.hello.test_redirect_response_endpoint - responses: - 302: - description: 302 Found - - /test-default-object-body: - post: - summary: Test if default object body param is passed to handler. - operationId: fakeapi.hello.test_default_object_body - parameters: - - name: stack - in: body - schema: - $ref: '#/definitions/new_stack' - default: - 'image_version': 'default_image' - responses: - 200: - description: OK - - /test-default-integer-body: - post: - summary: Test if default integer body param is passed to handler. - operationId: fakeapi.hello.test_default_integer_body - parameters: - - name: stack_version - in: body - schema: - type: integer - format: int32 - example: 1 - default: 1 - responses: - 200: - description: OK - - /test-empty-object-body: - post: - summary: Test if empty object body param is passed to handler. - operationId: fakeapi.hello.test_empty_object_body - parameters: - - name: stack - in: body - schema: - type: object - responses: - 200: - description: OK - - /resolver-test/method: - get: - summary: Test class instance method - operationId: fakeapi.hello.class_instance.test_method - responses: - 200: - description: OK - - /resolver-test/classmethod: - get: - summary: Test class instance method - operationId: fakeapi.hello.DummyClass.test_classmethod - responses: - 200: - description: OK - - /test_parameter_validation: - get: - operationId: fakeapi.hello.test_parameter_validation - parameters: - - name: date - in: query - type: string - format: date - - name: int - in: query - type: integer - - name: bool - in: query - type: boolean - responses: - 200: - description: OK - - /test_apikey_query_parameter_validation: - get: - operationId: fakeapi.hello.test_apikey_query_parameter_validation - parameters: - - name: name - in: query - type: string - security: - - api_key: [] - responses: - 200: - description: OK - - /test_required_query_param: - get: - operationId: fakeapi.hello.test_required_query_param - parameters: - - name: n - in: query - type: number - required: true - responses: - 200: - description: OK - - /test_array_csv_form_param: - post: - operationId: fakeapi.hello.test_array_csv_form_param - consumes: - - application/x-www-form-urlencoded - parameters: - - name: items - in: formData - description: An comma separated array of items - type: array - items: - type: string - collectionFormat: csv - default: ["squash", "banana"] - responses: - 200: - description: OK - - /test_array_pipes_form_param: - post: - operationId: fakeapi.hello.test_array_pipes_form_param - consumes: - - application/x-www-form-urlencoded - parameters: - - name: items - in: formData - description: An comma separated array of items - type: array - items: - type: integer - collectionFormat: pipes - responses: - 200: - description: OK - - /test_array_csv_query_param: - get: - operationId: fakeapi.hello.test_array_csv_query_param - parameters: - - name: items - in: query - description: An comma separated array of items - type: array - items: - type: string - collectionFormat: csv - default: ["squash", "banana"] - responses: - 200: - description: OK - - /test_array_multi_query_param: - get: - operationId: fakeapi.hello.test_array_multi_query_param - parameters: - - name: items - in: query - description: An comma separated array of items - type: array - items: - type: string - collectionFormat: multi - responses: - 200: - description: OK - - /test_array_pipes_query_param: - get: - operationId: fakeapi.hello.test_array_pipes_query_param - parameters: - - name: items - in: query - description: An pipe separated array of items - required: true - type: array - items: - type: integer - collectionFormat: pipes - responses: - 200: - description: OK - - /test_array_unsupported_query_param: - get: - operationId: fakeapi.hello.test_array_unsupported_query_param - parameters: - - name: items - in: query - description: An pipe separated array of items - required: true - type: array - items: - type: string - collectionFormat: tsv - responses: - 200: - description: OK - - /test-int-path/{someint}: - get: - summary: Test type casting of path parameter - operationId: fakeapi.hello.test_get_someint - parameters: - - name: someint - in: path - type: integer - required: true - responses: - 200: - description: OK - - /test-float-path/{somefloat}: - get: - summary: Test type casting of path parameter - operationId: fakeapi.hello.test_get_somefloat - parameters: - - name: somefloat - in: path - type: number - required: true - responses: - 200: - description: OK - - /test-default-query-parameter: - get: - summary: Test if default parameter is passed to function - operationId: fakeapi.hello.test_default_param - parameters: - - name: name - in: query - type: string - default: firetail - responses: - 200: - description: OK - - /test-falsy-param: - get: - summary: Test if default value when argument is falsy. - operationId: fakeapi.hello.test_falsy_param - parameters: - - name: falsy - type: integer - in: query - default: 1 - responses: - 200: - description: OK - - /test-formData-param: - post: - consumes: - - application/x-www-form-urlencoded - summary: Test formData parameter - operationId: fakeapi.hello.test_formdata_param - parameters: - - name: formData - type: string - in: formData - required: true - responses: - 200: - description: OK - - /test-formData-missing-param: - post: - summary: Test formData missing parameter in handler - operationId: fakeapi.hello.test_formdata_missing_param - parameters: - - name: missing_formData - type: string - in: formData - required: true - responses: - 200: - description: OK - - /test-formData-file-upload: - post: - summary: Test formData with file type, for file upload - operationId: fakeapi.hello.test_formdata_file_upload - consumes: - - multipart/form-data - parameters: - - name: formData - type: file - in: formData - required: true - responses: - 200: - description: OK - - /test-formData-file-upload-missing-param: - post: - summary: Test formData with file type, missing parameter in handler - operationId: fakeapi.hello.test_formdata_file_upload_missing_param - consumes: - - multipart/form-data - parameters: - - name: missing_formData - type: file - in: formData - required: true - responses: - 200: - description: OK - - /test-bool-param: - get: - summary: Test usage of boolean default value - operationId: fakeapi.hello.test_bool_default_param - parameters: - - name: thruthiness - type: boolean - in: query - default: false - responses: - 200: - description: OK - - /test-bool-array-param: - get: - summary: Test usage of an array of booleans value - operationId: fakeapi.hello.test_bool_array_param - parameters: - - name: thruthiness - in: query - type: array - items: - type: boolean - responses: - 200: - description: OK - - /test-required-param: - get: - summary: Test required param without default value - operationId: fakeapi.hello.test_required_param - parameters: - - name: simple - type: string - in: query - required: true - responses: - 200: - description: OK - - /test-cookie-param: - get: - summary: Test cookie parameter support. - operationId: fakeapi.hello.test_cookie_param - # No parameters because swagger / openapi 2.0 does not support describing cookie parameters. - responses: - 200: - description: OK - - /parameters-in-root-path: - parameters: - - in: query - name: title - type: string - description: Some parameter in the path - required: true - get: - summary: Test the method GET with parameter from path - operationId: fakeapi.hello.path_parameters_in_get_method - responses: - 200: - description: Everything is OK - responses: - 200: - description: OK - - /goodday/{name}: - post: - summary: Generate good day greeting - description: Generates a good day message. - operationId: fakeapi.hello.post_goodday - responses: - 201: - description: gooday response - headers: - Location: - type: string - description: The URI of the created resource - schema: - type: object - parameters: - - name: name - in: path - description: Name of the person to greet. - required: true - type: string - - /goodevening/{name}: - post: - summary: Generate good evening - description: Generates a good evening message. - operationId: fakeapi.hello.post_goodevening - produces: - - text/plain - responses: - 201: - description: goodevening response - headers: - Location: - type: string - description: The URI of the created resource - schema: - type: string - parameters: - - name: name - in: path - description: Name of the person to say good evening. - required: true - type: string - - /test-204-with-headers: - get: - summary: Tests that response code 204 can have headers set - operationId: fakeapi.hello.test_204_with_headers - responses: - 204: - headers: - X-Something: - description: A value that might be send in the response - type: string - description: 204 no content - - /test-204-with-headers-nocontent-obj: - get: - summary: Tests that response code 204 using NoContent obj can have headers set - operationId: fakeapi.hello.test_nocontent_obj_with_headers - responses: - 204: - headers: - X-Something: - description: A value that might be send in the response - type: string - description: 204 no content - - /test-array-in-path/{names}: - get: - operationId: fakeapi.hello.test_array_in_path - produces: - - application/json - parameters: - - name: names - description: List of names. - in: path - type: array - items: - type: string - required: true - collectionFormat: csv - responses: - 200: - description: OK - schema: - type: array - items: - type: string - - /nullable-parameters: - post: - operationId: fakeapi.hello.test_nullable_param_post - consumes: - - application/x-www-form-urlencoded - produces: - - application/json - parameters: - - name: post_param - description: Just a testing parameter. - in: formData - type: number - format: int32 - x-nullable: true - required: true - responses: - 200: - description: OK - put: - operationId: fakeapi.hello.test_nullable_param_put - produces: - - application/json - parameters: - - name: contents - description: Just a testing parameter. - in: body - x-nullable: true - required: true - schema: - type: object - properties: - name: - type: string - responses: - 200: - description: OK - get: - operationId: fakeapi.hello.test_nullable_parameters - produces: - - application/json - parameters: - - name: time_start - description: Just a testing parameter. - in: query - type: integer - format: int32 - x-nullable: true - required: true - responses: - 200: - description: OK - - /nullable-parameters-noargs: - put: - operationId: fakeapi.hello.test_nullable_param_put_noargs - produces: - - application/json - parameters: - - name: contents - description: Just a testing parameter. - in: body - x-nullable: true - required: true - schema: - type: object - properties: - name: - type: string - responses: - 200: - description: OK - - /custom-json-response: - get: - operationId: fakeapi.hello.test_custom_json_response - produces: - - application/json - responses: - 200: - description: OK - schema: - type: object - properties: - theResult: - type: string - description: the number we wanna test - - /blob-response: - get: - operationId: fakeapi.hello.get_blob_data - produces: - - "application/octet-stream" - responses: - 200: - description: Some blob response - schema: - type: string - format: binary - - /binary-response: - get: - operationId: fakeapi.hello.get_data_as_binary - produces: - - "application/octet-stream" - responses: - 200: - description: Everything is ok - schema: - type: string - - /query-params-as-kwargs: - get: - operationId: fakeapi.hello.test_args_kwargs - produces: - - application/json - parameters: - - name: foo - description: Just a testing parameter. - in: query - type: string - responses: - 200: - description: Return kwargs - schema: - type: object - - /body-params-as-kwargs: - post: - operationId: fakeapi.hello.test_args_kwargs_post - produces: - - application/json - parameters: - - name: $body - description: Just a testing parameter in the body - in: body - required: true - schema: - type: object - properties: - foo: - type: string - bar: - type: string - additionalProperties: true - responses: - 200: - description: Return kwargs - schema: - type: object - - /text-request: - post: - operationId: fakeapi.hello.get_data_as_text - consumes: - - "text/plain" - parameters: - - name: post_param - description: Just a testing parameter. - in: body - required: true - schema: - type: string - responses: - 200: - description: OK - - /param-sanitization: - post: - operationId: fakeapi.hello.test_param_sanitization - consumes: - - multipart/form-data - produces: - - application/json - parameters: - - name: $query - description: Just a testing parameter with an invalid Python name - in: query - type: string - - name: $form - description: Just a testing parameter in the form data - in: formData - type: string - responses: - 200: - description: Return parameters - schema: - type: object - - /body-sanitization: - post: - operationId: fakeapi.hello.test_body_sanitization - consumes: - - application/json - produces: - - application/json - parameters: - - name: $body - description: Just a testing parameter in the body - in: body - required: true - schema: - type: object - properties: - body1: - type: string - body2: - type: string - responses: - 200: - description: OK - - /body-sanitization-additional-properties: - post: - operationId: fakeapi.hello.test_body_sanitization_additional_properties - consumes: - - application/json - produces: - - application/json - parameters: - - name: $body - description: Just a testing parameter in the body - in: body - required: true - schema: - type: object - properties: - body1: - type: string - additionalProperties: true - responses: - 200: - description: OK - - /body-sanitization-additional-properties-defined: - post: - operationId: fakeapi.hello.test_body_sanitization_additional_properties_defined - consumes: - - application/json - produces: - - application/json - parameters: - - name: $body - description: Just a testing parameter in the body - in: body - required: true - schema: - type: object - properties: - body1: - type: string - additionalProperties: - type: string - responses: - 200: - description: OK - - /body-not-allowed-additional-properties: - post: - operationId: fakeapi.hello.test_body_not_allowed_additional_properties - consumes: - - application/json - produces: - - application/json - parameters: - - name: $body - description: Just a testing parameter in the body - in: body - required: true - schema: - type: object - properties: - body1: - type: string - additionalProperties: false - responses: - 200: - description: OK - - /get_non_conforming_response: - get: - operationId: fakeapi.hello.get_empty_dict - responses: - 200: - description: OK - schema: - type: object - required: - - some - properties: - some: - type: string - /post_wrong_content_type: - post: - operationId: fakeapi.hello.post_wrong_content_type - consumes: - - application/json - parameters: - - name: $body - description: Just a testing parameter in the body - in: body - required: true - schema: - type: object - properties: - body1: - type: string - body2: - type: string - responses: - 200: - description: OK - 215: - description: NOT-OK - - /get_unicode_request: - get: - summary: Test if a unicode string in query parameter works properly in Python 2 - operationId: fakeapi.hello.get_unicode_query - parameters: - - name: price - in: query - type: string - responses: - 200: - description: OK - - /get_unicode_response: - get: - operationId: fakeapi.hello.get_unicode_data - produces: - - "application/json" - responses: - 200: - description: Some unicode response - schema: - type: object - - /get_enum_response: - get: - operationId: fakeapi.hello.get_enum_response - produces: - - "application/json" - responses: - 200: - description: Some HTTPStatus response - schema: - type: object - - /get_httpstatus_response: - get: - operationId: fakeapi.hello.get_httpstatus_response - produces: - - "application/json" - responses: - 200: - description: Some HTTPStatus response - schema: - type: object - - /get_bad_default_response/{response_code}: - get: - operationId: fakeapi.hello.get_bad_default_response - produces: - - "application/json" - parameters: - - name: response_code - in: path - type: integer - required: true - responses: - 200: - description: Some object response - schema: - type: object - default: - description: Some array response - schema: - type: array - items: - type: integer - - /forward: - post: - operationId: fakeapi.hello.forward - consumes: - - application/json - produces: - - application/json - parameters: - - name: body - in: body - required: true - schema: - type: object - responses: - 200: - description: > - The response containing the same data as were present in request body. - schema: - type: object - - /get_streaming_response: - get: - operationId: fakeapi.hello.get_streaming_response - responses: - '200': - description: OK - schema: - type: file - -definitions: - new_stack: - type: object - properties: - image_version: - type: string - description: Docker image version to deploy - required: - - image_version - -securityDefinitions: - api_key: - type: apiKey - name: apikey - in: query - x-apikeyInfoFunc: fakeapi.hello.apikey_info +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /v1.0 + +paths: + /greeting/{name}: + post: + summary: Generate greeting + description: Generates a greeting message. + operationId: fakeapi.hello.post_greeting + responses: + '200': + description: greeting response + schema: + type: object + parameters: + - name: name + in: path + description: Name of the person to greet. + required: true + type: string + /greeting/{name}/{remainder}: + post: + summary: Generate greeting and collect the remainder of the url + description: Generates a greeting message and includes the rest of the url. + operationId: fakeapi.hello.post_greeting_url + responses: + '200': + description: greeting response with url + schema: + type: object + parameters: + - name: name + in: path + description: Name of the person to greet. + required: true + type: string + - name: remainder + in: path + description: the rest of the url + required: true + type: string + format: path + /greetings/{name}: + get: + summary: Generate greeting + description: Generates a greeting message with custom mimetype + operationId: fakeapi.hello.get_greetings + produces: + - application/x.firetail+json + responses: + '200': + description: greeting response + schema: + type: object + parameters: + - name: name + in: path + description: Name of the person to greet. + required: true + type: string + + /bye/{name}: + get: + summary: Generate goodbye + description: Generates a goodbye message. + operationId: fakeapi.hello.get_bye + produces: + - text/plain + responses: + '200': + description: goodbye response + schema: + type: string + default: + description: "unexpected error" + parameters: + - name: name + in: path + description: Name of the person to say bye. + required: true + type: string + + /flask_response_tuple: + get: + summary: Return flask response tuple + description: Test returning a flask response tuple + operationId: fakeapi.hello.get_flask_response_tuple + produces: + - application/json + responses: + 200: + description: json response + schema: + type: object + + /list/{name}: + get: + summary: Generate a greeting in a list + description: Generate a greeting in a list + operationId: fakeapi.hello.get_list + produces: + - application/json + responses: + 200: + description: a greeting in a list + schema: + type: array + items: + type: string + parameters: + - name: name + in: path + description: Name of the person to say hello to. + required: true + type: string + + /test_no_content_response: + get: + operationId: fakeapi.hello.test_no_content_response + responses: + 204: + description: No content returned + + /multimime: + get: + summary: Has multiple content types + description: Has multiple content types + operationId: fakeapi.hello.multimime + produces: + - application/json + - application/xml + responses: + 200: + description: goodbye response + schema: + type: string + + /empty: + get: + summary: Returns empty response + description: Returns empty response + operationId: fakeapi.hello.empty + produces: + - application/json + responses: + 204: + description: empty + + /test-redirect-endpoint: + get: + summary: Tests handlers returning flask.Response objects + operationId: fakeapi.hello.test_redirect_endpoint + responses: + 302: + description: 302 Found + + /test-redirect-response-endpoint: + get: + summary: Tests handlers returning flask.Response objects + operationId: fakeapi.hello.test_redirect_response_endpoint + responses: + 302: + description: 302 Found + + /test-default-object-body: + post: + summary: Test if default object body param is passed to handler. + operationId: fakeapi.hello.test_default_object_body + parameters: + - name: stack + in: body + schema: + $ref: '#/definitions/new_stack' + default: + 'image_version': 'default_image' + responses: + 200: + description: OK + + /test-default-integer-body: + post: + summary: Test if default integer body param is passed to handler. + operationId: fakeapi.hello.test_default_integer_body + parameters: + - name: stack_version + in: body + schema: + type: integer + format: int32 + example: 1 + default: 1 + responses: + 200: + description: OK + + /test-empty-object-body: + post: + summary: Test if empty object body param is passed to handler. + operationId: fakeapi.hello.test_empty_object_body + parameters: + - name: stack + in: body + schema: + type: object + responses: + 200: + description: OK + + /resolver-test/method: + get: + summary: Test class instance method + operationId: fakeapi.hello.class_instance.test_method + responses: + 200: + description: OK + + /resolver-test/classmethod: + get: + summary: Test class instance method + operationId: fakeapi.hello.DummyClass.test_classmethod + responses: + 200: + description: OK + + /test_parameter_validation: + get: + operationId: fakeapi.hello.test_parameter_validation + parameters: + - name: date + in: query + type: string + format: date + - name: int + in: query + type: integer + - name: bool + in: query + type: boolean + responses: + 200: + description: OK + + /test_apikey_query_parameter_validation: + get: + operationId: fakeapi.hello.test_apikey_query_parameter_validation + parameters: + - name: name + in: query + type: string + security: + - api_key: [] + responses: + 200: + description: OK + + /test_required_query_param: + get: + operationId: fakeapi.hello.test_required_query_param + parameters: + - name: n + in: query + type: number + required: true + responses: + 200: + description: OK + + /test_array_csv_form_param: + post: + operationId: fakeapi.hello.test_array_csv_form_param + consumes: + - application/x-www-form-urlencoded + parameters: + - name: items + in: formData + description: An comma separated array of items + type: array + items: + type: string + collectionFormat: csv + default: ["squash", "banana"] + responses: + 200: + description: OK + + /test_array_pipes_form_param: + post: + operationId: fakeapi.hello.test_array_pipes_form_param + consumes: + - application/x-www-form-urlencoded + parameters: + - name: items + in: formData + description: An comma separated array of items + type: array + items: + type: integer + collectionFormat: pipes + responses: + 200: + description: OK + + /test_array_csv_query_param: + get: + operationId: fakeapi.hello.test_array_csv_query_param + parameters: + - name: items + in: query + description: An comma separated array of items + type: array + items: + type: string + collectionFormat: csv + default: ["squash", "banana"] + responses: + 200: + description: OK + + /test_array_multi_query_param: + get: + operationId: fakeapi.hello.test_array_multi_query_param + parameters: + - name: items + in: query + description: An comma separated array of items + type: array + items: + type: string + collectionFormat: multi + responses: + 200: + description: OK + + /test_array_pipes_query_param: + get: + operationId: fakeapi.hello.test_array_pipes_query_param + parameters: + - name: items + in: query + description: An pipe separated array of items + required: true + type: array + items: + type: integer + collectionFormat: pipes + responses: + 200: + description: OK + + /test_array_unsupported_query_param: + get: + operationId: fakeapi.hello.test_array_unsupported_query_param + parameters: + - name: items + in: query + description: An pipe separated array of items + required: true + type: array + items: + type: string + collectionFormat: tsv + responses: + 200: + description: OK + + /test-int-path/{someint}: + get: + summary: Test type casting of path parameter + operationId: fakeapi.hello.test_get_someint + parameters: + - name: someint + in: path + type: integer + required: true + responses: + 200: + description: OK + + /test-float-path/{somefloat}: + get: + summary: Test type casting of path parameter + operationId: fakeapi.hello.test_get_somefloat + parameters: + - name: somefloat + in: path + type: number + required: true + responses: + 200: + description: OK + + /test-default-query-parameter: + get: + summary: Test if default parameter is passed to function + operationId: fakeapi.hello.test_default_param + parameters: + - name: name + in: query + type: string + default: firetail + responses: + 200: + description: OK + + /test-falsy-param: + get: + summary: Test if default value when argument is falsy. + operationId: fakeapi.hello.test_falsy_param + parameters: + - name: falsy + type: integer + in: query + default: 1 + responses: + 200: + description: OK + + /test-formData-param: + post: + consumes: + - application/x-www-form-urlencoded + summary: Test formData parameter + operationId: fakeapi.hello.test_formdata_param + parameters: + - name: formData + type: string + in: formData + required: true + responses: + 200: + description: OK + + /test-formData-missing-param: + post: + summary: Test formData missing parameter in handler + operationId: fakeapi.hello.test_formdata_missing_param + parameters: + - name: missing_formData + type: string + in: formData + required: true + responses: + 200: + description: OK + + /test-formData-file-upload: + post: + summary: Test formData with file type, for file upload + operationId: fakeapi.hello.test_formdata_file_upload + consumes: + - multipart/form-data + parameters: + - name: formData + type: file + in: formData + required: true + responses: + 200: + description: OK + + /test-formData-file-upload-missing-param: + post: + summary: Test formData with file type, missing parameter in handler + operationId: fakeapi.hello.test_formdata_file_upload_missing_param + consumes: + - multipart/form-data + parameters: + - name: missing_formData + type: file + in: formData + required: true + responses: + 200: + description: OK + + /test-bool-param: + get: + summary: Test usage of boolean default value + operationId: fakeapi.hello.test_bool_default_param + parameters: + - name: thruthiness + type: boolean + in: query + default: false + responses: + 200: + description: OK + + /test-bool-array-param: + get: + summary: Test usage of an array of booleans value + operationId: fakeapi.hello.test_bool_array_param + parameters: + - name: thruthiness + in: query + type: array + items: + type: boolean + responses: + 200: + description: OK + + /test-required-param: + get: + summary: Test required param without default value + operationId: fakeapi.hello.test_required_param + parameters: + - name: simple + type: string + in: query + required: true + responses: + 200: + description: OK + + /test-cookie-param: + get: + summary: Test cookie parameter support. + operationId: fakeapi.hello.test_cookie_param + # No parameters because swagger / openapi 2.0 does not support describing cookie parameters. + responses: + 200: + description: OK + + /parameters-in-root-path: + parameters: + - in: query + name: title + type: string + description: Some parameter in the path + required: true + get: + summary: Test the method GET with parameter from path + operationId: fakeapi.hello.path_parameters_in_get_method + responses: + 200: + description: Everything is OK + responses: + 200: + description: OK + + /goodday/{name}: + post: + summary: Generate good day greeting + description: Generates a good day message. + operationId: fakeapi.hello.post_goodday + responses: + 201: + description: gooday response + headers: + Location: + type: string + description: The URI of the created resource + schema: + type: object + parameters: + - name: name + in: path + description: Name of the person to greet. + required: true + type: string + + /goodevening/{name}: + post: + summary: Generate good evening + description: Generates a good evening message. + operationId: fakeapi.hello.post_goodevening + produces: + - text/plain + responses: + 201: + description: goodevening response + headers: + Location: + type: string + description: The URI of the created resource + schema: + type: string + parameters: + - name: name + in: path + description: Name of the person to say good evening. + required: true + type: string + + /test-204-with-headers: + get: + summary: Tests that response code 204 can have headers set + operationId: fakeapi.hello.test_204_with_headers + responses: + 204: + headers: + X-Something: + description: A value that might be send in the response + type: string + description: 204 no content + + /test-204-with-headers-nocontent-obj: + get: + summary: Tests that response code 204 using NoContent obj can have headers set + operationId: fakeapi.hello.test_nocontent_obj_with_headers + responses: + 204: + headers: + X-Something: + description: A value that might be send in the response + type: string + description: 204 no content + + /test-array-in-path/{names}: + get: + operationId: fakeapi.hello.test_array_in_path + produces: + - application/json + parameters: + - name: names + description: List of names. + in: path + type: array + items: + type: string + required: true + collectionFormat: csv + responses: + 200: + description: OK + schema: + type: array + items: + type: string + + /nullable-parameters: + post: + operationId: fakeapi.hello.test_nullable_param_post + consumes: + - application/x-www-form-urlencoded + produces: + - application/json + parameters: + - name: post_param + description: Just a testing parameter. + in: formData + type: number + format: int32 + x-nullable: true + required: true + responses: + 200: + description: OK + put: + operationId: fakeapi.hello.test_nullable_param_put + produces: + - application/json + parameters: + - name: contents + description: Just a testing parameter. + in: body + x-nullable: true + required: true + schema: + type: object + properties: + name: + type: string + responses: + 200: + description: OK + get: + operationId: fakeapi.hello.test_nullable_parameters + produces: + - application/json + parameters: + - name: time_start + description: Just a testing parameter. + in: query + type: integer + format: int32 + x-nullable: true + required: true + responses: + 200: + description: OK + + /nullable-parameters-noargs: + put: + operationId: fakeapi.hello.test_nullable_param_put_noargs + produces: + - application/json + parameters: + - name: contents + description: Just a testing parameter. + in: body + x-nullable: true + required: true + schema: + type: object + properties: + name: + type: string + responses: + 200: + description: OK + + /custom-json-response: + get: + operationId: fakeapi.hello.test_custom_json_response + produces: + - application/json + responses: + 200: + description: OK + schema: + type: object + properties: + theResult: + type: string + description: the number we wanna test + + /blob-response: + get: + operationId: fakeapi.hello.get_blob_data + produces: + - "application/octet-stream" + responses: + 200: + description: Some blob response + schema: + type: string + format: binary + + /binary-response: + get: + operationId: fakeapi.hello.get_data_as_binary + produces: + - "application/octet-stream" + responses: + 200: + description: Everything is ok + schema: + type: string + + /query-params-as-kwargs: + get: + operationId: fakeapi.hello.test_args_kwargs + produces: + - application/json + parameters: + - name: foo + description: Just a testing parameter. + in: query + type: string + responses: + 200: + description: Return kwargs + schema: + type: object + + /body-params-as-kwargs: + post: + operationId: fakeapi.hello.test_args_kwargs_post + produces: + - application/json + parameters: + - name: $body + description: Just a testing parameter in the body + in: body + required: true + schema: + type: object + properties: + foo: + type: string + bar: + type: string + additionalProperties: true + responses: + 200: + description: Return kwargs + schema: + type: object + + /text-request: + post: + operationId: fakeapi.hello.get_data_as_text + consumes: + - "text/plain" + parameters: + - name: post_param + description: Just a testing parameter. + in: body + required: true + schema: + type: string + responses: + 200: + description: OK + + /param-sanitization: + post: + operationId: fakeapi.hello.test_param_sanitization + consumes: + - multipart/form-data + produces: + - application/json + parameters: + - name: $query + description: Just a testing parameter with an invalid Python name + in: query + type: string + - name: $form + description: Just a testing parameter in the form data + in: formData + type: string + responses: + 200: + description: Return parameters + schema: + type: object + + /body-sanitization: + post: + operationId: fakeapi.hello.test_body_sanitization + consumes: + - application/json + produces: + - application/json + parameters: + - name: $body + description: Just a testing parameter in the body + in: body + required: true + schema: + type: object + properties: + body1: + type: string + body2: + type: string + responses: + 200: + description: OK + + /body-sanitization-additional-properties: + post: + operationId: fakeapi.hello.test_body_sanitization_additional_properties + consumes: + - application/json + produces: + - application/json + parameters: + - name: $body + description: Just a testing parameter in the body + in: body + required: true + schema: + type: object + properties: + body1: + type: string + additionalProperties: true + responses: + 200: + description: OK + + /body-sanitization-additional-properties-defined: + post: + operationId: fakeapi.hello.test_body_sanitization_additional_properties_defined + consumes: + - application/json + produces: + - application/json + parameters: + - name: $body + description: Just a testing parameter in the body + in: body + required: true + schema: + type: object + properties: + body1: + type: string + additionalProperties: + type: string + responses: + 200: + description: OK + + /body-not-allowed-additional-properties: + post: + operationId: fakeapi.hello.test_body_not_allowed_additional_properties + consumes: + - application/json + produces: + - application/json + parameters: + - name: $body + description: Just a testing parameter in the body + in: body + required: true + schema: + type: object + properties: + body1: + type: string + additionalProperties: false + responses: + 200: + description: OK + + /get_non_conforming_response: + get: + operationId: fakeapi.hello.get_empty_dict + responses: + 200: + description: OK + schema: + type: object + required: + - some + properties: + some: + type: string + /post_wrong_content_type: + post: + operationId: fakeapi.hello.post_wrong_content_type + consumes: + - application/json + parameters: + - name: $body + description: Just a testing parameter in the body + in: body + required: true + schema: + type: object + properties: + body1: + type: string + body2: + type: string + responses: + 200: + description: OK + 215: + description: NOT-OK + + /get_unicode_request: + get: + summary: Test if a unicode string in query parameter works properly in Python 2 + operationId: fakeapi.hello.get_unicode_query + parameters: + - name: price + in: query + type: string + responses: + 200: + description: OK + + /get_unicode_response: + get: + operationId: fakeapi.hello.get_unicode_data + produces: + - "application/json" + responses: + 200: + description: Some unicode response + schema: + type: object + + /get_enum_response: + get: + operationId: fakeapi.hello.get_enum_response + produces: + - "application/json" + responses: + 200: + description: Some HTTPStatus response + schema: + type: object + + /get_httpstatus_response: + get: + operationId: fakeapi.hello.get_httpstatus_response + produces: + - "application/json" + responses: + 200: + description: Some HTTPStatus response + schema: + type: object + + /get_bad_default_response/{response_code}: + get: + operationId: fakeapi.hello.get_bad_default_response + produces: + - "application/json" + parameters: + - name: response_code + in: path + type: integer + required: true + responses: + 200: + description: Some object response + schema: + type: object + default: + description: Some array response + schema: + type: array + items: + type: integer + + /forward: + post: + operationId: fakeapi.hello.forward + consumes: + - application/json + produces: + - application/json + parameters: + - name: body + in: body + required: true + schema: + type: object + responses: + 200: + description: > + The response containing the same data as were present in request body. + schema: + type: object + + /get_streaming_response: + get: + operationId: fakeapi.hello.get_streaming_response + responses: + '200': + description: OK + schema: + type: file + +definitions: + new_stack: + type: object + properties: + image_version: + type: string + description: Docker image version to deploy + required: + - image_version + +securityDefinitions: + api_key: + type: apiKey + name: apikey + in: query + x-apikeyInfoFunc: fakeapi.hello.apikey_info diff --git a/tests/fixtures/snake_case/openapi.yaml b/tests/fixtures/snake_case/openapi.yaml index 3553bf0..bbf2411 100644 --- a/tests/fixtures/snake_case/openapi.yaml +++ b/tests/fixtures/snake_case/openapi.yaml @@ -1,218 +1,218 @@ -openapi: 3.0.0 -info: - title: '{{title}}' - version: '1.0' -paths: - '/test-get-path-snake/{SomeId}': - get: - summary: Test converting to snake_case in path - description: Test converting to snake_case in path - operationId: fakeapi.snake_case.get_path_snake - responses: - '200': - description: Success response - content: - application/json: - schema: - type: object - parameters: - - name: SomeId - in: path - description: SomeId parameter - required: true - schema: - type: integer - '/test-get-path-shadow/{id}': - get: - summary: Test converting to un-shadowed parameter in path - description: Test converting to un-shadowed parameter in path - operationId: fakeapi.snake_case.get_path_shadow - responses: - '200': - description: Success response - content: - application/json: - schema: - type: object - parameters: - - name: id - in: path - description: id parameter - required: true - schema: - type: integer - /test-get-query-snake: - get: - summary: Test converting to snake_case parameter in query - description: Test converting to snake_case parameter in query - operationId: fakeapi.snake_case.get_query_snake - responses: - '200': - description: Success response - content: - application/json: - schema: - type: object - parameters: - - name: someId - in: query - description: id parameter - required: true - schema: - type: integer - /test-get-query-shadow: - get: - summary: Test converting to un-shadowed parameter in query - description: est converting to un-shadowed parameter in query - operationId: fakeapi.snake_case.get_query_shadow - responses: - '200': - description: Success response - content: - application/json: - schema: - type: object - parameters: - - name: list - in: query - description: id parameter - required: true - schema: - type: integer - /test-get-camel-case-version: - get: - summary: Test for CamelCase version of query parameter - description: Test for when a wrongly cased parameter is supplied. - operationId: fakeapi.snake_case.get_camelcase - responses: - '200': - description: OK - parameters: - - name: truthiness - in: query - description: A test parameter, for which 'Truthiness' will be given in the request. - schema: - type: boolean - default: false - - name: orderBy - in: query - description: A CamelCase parameter, for which 'order_by' will be given in the request. - schema: - type: string - - '/test-post-path-snake/{SomeId}': - post: - summary: Test converting to snake_case in path - description: Test converting to snake_case in path - operationId: fakeapi.snake_case.post_path_snake - responses: - '200': - description: greeting response - content: - 'application/json': - schema: - type: object - parameters: - - name: SomeId - in: path - description: SomeId parameter - required: true - schema: - type: integer - requestBody: - content: - application/json: - schema: - x-body-name: some_other_id - type: object - description: SomeOtherId parameter - required: true - '/test-post-path-shadow/{id}': - post: - summary: Test converting to un-shadowed in path - description: Test converting to un-shadowed in path - operationId: fakeapi.snake_case.post_path_shadow - responses: - '200': - description: greeting response - content: - 'application/json': - schema: - type: object - parameters: - - name: id - in: path - description: id parameter - required: true - schema: - type: integer - requestBody: - content: - application/json: - schema: - x-body-name: round_ - type: object - description: round parameter - required: true - /test-post-query-snake: - post: - summary: Test converting to snake_case in query - description: Test converting to snake_case in query - operationId: fakeapi.snake_case.post_query_snake - responses: - '200': - description: greeting response - content: - 'application/json': - schema: - type: object - parameters: - - name: someId - in: query - description: someId parameter - required: true - schema: - type: integer - requestBody: - content: - application/json: - schema: - x-body-name: some_other_id - type: object - description: someOtherId parameter - required: true - /test-post-query-shadow: - post: - summary: Test converting to un-shadowed in query - description: Test converting to un-shadowed in query - operationId: fakeapi.snake_case.post_query_shadow - responses: - '200': - description: greeting response - content: - 'application/json': - schema: - type: object - parameters: - - name: id - in: query - description: id parameter - required: true - schema: - type: integer - - name: class - in: query - description: class parameter - required: true - schema: - type: string - requestBody: - content: - application/json: - schema: - x-body-name: next_ - type: object - description: next parameter - required: true -servers: - - url: /v1.0 +openapi: 3.0.0 +info: + title: '{{title}}' + version: '1.0' +paths: + '/test-get-path-snake/{SomeId}': + get: + summary: Test converting to snake_case in path + description: Test converting to snake_case in path + operationId: fakeapi.snake_case.get_path_snake + responses: + '200': + description: Success response + content: + application/json: + schema: + type: object + parameters: + - name: SomeId + in: path + description: SomeId parameter + required: true + schema: + type: integer + '/test-get-path-shadow/{id}': + get: + summary: Test converting to un-shadowed parameter in path + description: Test converting to un-shadowed parameter in path + operationId: fakeapi.snake_case.get_path_shadow + responses: + '200': + description: Success response + content: + application/json: + schema: + type: object + parameters: + - name: id + in: path + description: id parameter + required: true + schema: + type: integer + /test-get-query-snake: + get: + summary: Test converting to snake_case parameter in query + description: Test converting to snake_case parameter in query + operationId: fakeapi.snake_case.get_query_snake + responses: + '200': + description: Success response + content: + application/json: + schema: + type: object + parameters: + - name: someId + in: query + description: id parameter + required: true + schema: + type: integer + /test-get-query-shadow: + get: + summary: Test converting to un-shadowed parameter in query + description: est converting to un-shadowed parameter in query + operationId: fakeapi.snake_case.get_query_shadow + responses: + '200': + description: Success response + content: + application/json: + schema: + type: object + parameters: + - name: list + in: query + description: id parameter + required: true + schema: + type: integer + /test-get-camel-case-version: + get: + summary: Test for CamelCase version of query parameter + description: Test for when a wrongly cased parameter is supplied. + operationId: fakeapi.snake_case.get_camelcase + responses: + '200': + description: OK + parameters: + - name: truthiness + in: query + description: A test parameter, for which 'Truthiness' will be given in the request. + schema: + type: boolean + default: false + - name: orderBy + in: query + description: A CamelCase parameter, for which 'order_by' will be given in the request. + schema: + type: string + + '/test-post-path-snake/{SomeId}': + post: + summary: Test converting to snake_case in path + description: Test converting to snake_case in path + operationId: fakeapi.snake_case.post_path_snake + responses: + '200': + description: greeting response + content: + 'application/json': + schema: + type: object + parameters: + - name: SomeId + in: path + description: SomeId parameter + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + x-body-name: some_other_id + type: object + description: SomeOtherId parameter + required: true + '/test-post-path-shadow/{id}': + post: + summary: Test converting to un-shadowed in path + description: Test converting to un-shadowed in path + operationId: fakeapi.snake_case.post_path_shadow + responses: + '200': + description: greeting response + content: + 'application/json': + schema: + type: object + parameters: + - name: id + in: path + description: id parameter + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + x-body-name: round_ + type: object + description: round parameter + required: true + /test-post-query-snake: + post: + summary: Test converting to snake_case in query + description: Test converting to snake_case in query + operationId: fakeapi.snake_case.post_query_snake + responses: + '200': + description: greeting response + content: + 'application/json': + schema: + type: object + parameters: + - name: someId + in: query + description: someId parameter + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + x-body-name: some_other_id + type: object + description: someOtherId parameter + required: true + /test-post-query-shadow: + post: + summary: Test converting to un-shadowed in query + description: Test converting to un-shadowed in query + operationId: fakeapi.snake_case.post_query_shadow + responses: + '200': + description: greeting response + content: + 'application/json': + schema: + type: object + parameters: + - name: id + in: query + description: id parameter + required: true + schema: + type: integer + - name: class + in: query + description: class parameter + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + x-body-name: next_ + type: object + description: next parameter + required: true +servers: + - url: /v1.0 diff --git a/tests/fixtures/snake_case/swagger.yaml b/tests/fixtures/snake_case/swagger.yaml index 61e4e9b..c86a6bf 100644 --- a/tests/fixtures/snake_case/swagger.yaml +++ b/tests/fixtures/snake_case/swagger.yaml @@ -1,189 +1,189 @@ -swagger: "2.0" -info: - title: "{{title}}" - version: "1.0" -basePath: /v1.0 -paths: - /test-get-path-snake/{SomeId}: - get: - summary: Test converting to snake_case in path - description: Test converting to snake_case in path - operationId: fakeapi.snake_case.get_path_snake - produces: - - application/json - responses: - '200': - description: Success response - schema: - type: object - parameters: - - name: SomeId - in: path - description: SomeId parameter - required: true - type: integer - /test-get-path-shadow/{id}: - get: - summary: Test converting to un-shadowed parameter in path - description: Test converting to un-shadowed parameter in path - operationId: fakeapi.snake_case.get_path_shadow - produces: - - application/json - responses: - '200': - description: Success response - schema: - type: object - parameters: - - name: id - in: path - description: id parameter - required: true - type: integer - /test-get-query-snake: - get: - summary: Test converting to snake_case parameter in query - description: Test converting to snake_case parameter in query - operationId: fakeapi.snake_case.get_query_snake - produces: - - application/json - responses: - '200': - description: Success response - schema: - type: object - parameters: - - name: someId - in: query - description: id parameter - required: true - type: integer - /test-get-query-shadow: - get: - summary: Test converting to un-shadowed parameter in query - description: est converting to un-shadowed parameter in query - operationId: fakeapi.snake_case.get_query_shadow - produces: - - application/json - responses: - '200': - description: Success response - schema: - type: object - parameters: - - name: list - in: query - description: id parameter - required: true - type: integer - /test-post-path-snake/{SomeId}: - post: - summary: Test converting to snake_case in path - description: Test converting to snake_case in path - operationId: fakeapi.snake_case.post_path_snake - responses: - '200': - description: greeting response - schema: - type: object - parameters: - - name: SomeId - in: path - description: SomeId parameter - required: true - type: integer - - name: SomeOtherId - in: body - description: SomeOtherId parameter - required: true - schema: - type: object - /test-post-path-shadow/{id}: - post: - summary: Test converting to un-shadowed in path - description: Test converting to un-shadowed in path - operationId: fakeapi.snake_case.post_path_shadow - responses: - '200': - description: greeting response - schema: - type: object - parameters: - - name: id - in: path - description: id parameter - required: true - type: integer - - name: round - in: body - description: round parameter - required: true - schema: - type: object - /test-post-query-snake: - post: - summary: Test converting to snake_case in query - description: Test converting to snake_case in query - operationId: fakeapi.snake_case.post_query_snake - responses: - '200': - description: greeting response - schema: - type: object - parameters: - - name: someId - in: query - description: someId parameter - required: true - type: integer - - name: SomeOtherId - in: body - description: someOtherId parameter - required: true - schema: - type: object - /test-post-query-shadow: - post: - summary: Test converting to un-shadowed in query - description: Test converting to un-shadowed in query - operationId: fakeapi.snake_case.post_query_shadow - responses: - '200': - description: greeting response - schema: - type: object - parameters: - - name: id - in: query - description: id parameter - required: true - type: integer - - name: class - in: query - description: class parameter - required: true - type: string - - name: next - in: body - description: next parameter - required: true - schema: - type: object - /test-get-camel-case-version: - get: - summary: Test for CamelCase version of query parameter - description: Test for when the CamelCase version of a query parameter is provided. - operationId: fakeapi.snake_case.get_camelcase - responses: - '200': - description: OK - parameters: - - name: truthiness - in: query - description: A test parameter, for which 'Truthiness' will be given in the request. - type: boolean - default: false - - name: orderBy - in: query - description: A CamelCase parameter, for which 'order_by' will be given in the request. - type: string +swagger: "2.0" +info: + title: "{{title}}" + version: "1.0" +basePath: /v1.0 +paths: + /test-get-path-snake/{SomeId}: + get: + summary: Test converting to snake_case in path + description: Test converting to snake_case in path + operationId: fakeapi.snake_case.get_path_snake + produces: + - application/json + responses: + '200': + description: Success response + schema: + type: object + parameters: + - name: SomeId + in: path + description: SomeId parameter + required: true + type: integer + /test-get-path-shadow/{id}: + get: + summary: Test converting to un-shadowed parameter in path + description: Test converting to un-shadowed parameter in path + operationId: fakeapi.snake_case.get_path_shadow + produces: + - application/json + responses: + '200': + description: Success response + schema: + type: object + parameters: + - name: id + in: path + description: id parameter + required: true + type: integer + /test-get-query-snake: + get: + summary: Test converting to snake_case parameter in query + description: Test converting to snake_case parameter in query + operationId: fakeapi.snake_case.get_query_snake + produces: + - application/json + responses: + '200': + description: Success response + schema: + type: object + parameters: + - name: someId + in: query + description: id parameter + required: true + type: integer + /test-get-query-shadow: + get: + summary: Test converting to un-shadowed parameter in query + description: est converting to un-shadowed parameter in query + operationId: fakeapi.snake_case.get_query_shadow + produces: + - application/json + responses: + '200': + description: Success response + schema: + type: object + parameters: + - name: list + in: query + description: id parameter + required: true + type: integer + /test-post-path-snake/{SomeId}: + post: + summary: Test converting to snake_case in path + description: Test converting to snake_case in path + operationId: fakeapi.snake_case.post_path_snake + responses: + '200': + description: greeting response + schema: + type: object + parameters: + - name: SomeId + in: path + description: SomeId parameter + required: true + type: integer + - name: SomeOtherId + in: body + description: SomeOtherId parameter + required: true + schema: + type: object + /test-post-path-shadow/{id}: + post: + summary: Test converting to un-shadowed in path + description: Test converting to un-shadowed in path + operationId: fakeapi.snake_case.post_path_shadow + responses: + '200': + description: greeting response + schema: + type: object + parameters: + - name: id + in: path + description: id parameter + required: true + type: integer + - name: round + in: body + description: round parameter + required: true + schema: + type: object + /test-post-query-snake: + post: + summary: Test converting to snake_case in query + description: Test converting to snake_case in query + operationId: fakeapi.snake_case.post_query_snake + responses: + '200': + description: greeting response + schema: + type: object + parameters: + - name: someId + in: query + description: someId parameter + required: true + type: integer + - name: SomeOtherId + in: body + description: someOtherId parameter + required: true + schema: + type: object + /test-post-query-shadow: + post: + summary: Test converting to un-shadowed in query + description: Test converting to un-shadowed in query + operationId: fakeapi.snake_case.post_query_shadow + responses: + '200': + description: greeting response + schema: + type: object + parameters: + - name: id + in: query + description: id parameter + required: true + type: integer + - name: class + in: query + description: class parameter + required: true + type: string + - name: next + in: body + description: next parameter + required: true + schema: + type: object + /test-get-camel-case-version: + get: + summary: Test for CamelCase version of query parameter + description: Test for when the CamelCase version of a query parameter is provided. + operationId: fakeapi.snake_case.get_camelcase + responses: + '200': + description: OK + parameters: + - name: truthiness + in: query + description: A test parameter, for which 'Truthiness' will be given in the request. + type: boolean + default: false + - name: orderBy + in: query + description: A CamelCase parameter, for which 'order_by' will be given in the request. + type: string diff --git a/tests/fixtures/unordered_definition/openapi.yaml b/tests/fixtures/unordered_definition/openapi.yaml index 1895c68..9e73bf4 100644 --- a/tests/fixtures/unordered_definition/openapi.yaml +++ b/tests/fixtures/unordered_definition/openapi.yaml @@ -1,31 +1,31 @@ -openapi: 3.0.0 -info: - title: '{{title}}' - version: '1.0' -paths: - '/unordered-params/{path_param}': - get: - summary: Mixed parameters in swagger definition - operationId: fakeapi.hello.unordered_params_response - responses: - '200': - description: OK - parameters: - - name: first - in: query - description: First Param - schema: - type: integer - - name: path_param - in: path - required: true - description: Path Param - schema: - type: string - - name: second - in: query - description: Second Param - schema: - type: integer -servers: - - url: /v1.0 +openapi: 3.0.0 +info: + title: '{{title}}' + version: '1.0' +paths: + '/unordered-params/{path_param}': + get: + summary: Mixed parameters in swagger definition + operationId: fakeapi.hello.unordered_params_response + responses: + '200': + description: OK + parameters: + - name: first + in: query + description: First Param + schema: + type: integer + - name: path_param + in: path + required: true + description: Path Param + schema: + type: string + - name: second + in: query + description: Second Param + schema: + type: integer +servers: + - url: /v1.0 diff --git a/tests/fixtures/unordered_definition/swagger.yaml b/tests/fixtures/unordered_definition/swagger.yaml index 5071cf6..d6e1d70 100644 --- a/tests/fixtures/unordered_definition/swagger.yaml +++ b/tests/fixtures/unordered_definition/swagger.yaml @@ -1,30 +1,30 @@ -swagger: "2.0" - -info: - title: "{{title}}" - version: "1.0" - -basePath: /v1.0 - -paths: - /unordered-params/{path_param}: - get: - summary: Mixed parameters in swagger definition - operationId: fakeapi.hello.unordered_params_response - responses: - 200: - description: OK - parameters: - - name: first - in: query - type: integer - description: First Param - - name: path_param - in: path - required: true - type: string - description: Path Param - - name: second - in: query - type: integer - description: Second Param +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /v1.0 + +paths: + /unordered-params/{path_param}: + get: + summary: Mixed parameters in swagger definition + operationId: fakeapi.hello.unordered_params_response + responses: + 200: + description: OK + parameters: + - name: first + in: query + type: integer + description: First Param + - name: path_param + in: path + required: true + type: string + description: Path Param + - name: second + in: query + type: integer + description: Second Param diff --git a/tests/fixtures/user_module_loading_error/openapi.yaml b/tests/fixtures/user_module_loading_error/openapi.yaml index 0f660b3..6f7c2d9 100644 --- a/tests/fixtures/user_module_loading_error/openapi.yaml +++ b/tests/fixtures/user_module_loading_error/openapi.yaml @@ -1,17 +1,17 @@ -openapi: 3.0.0 -info: - title: '{{title}}' - version: '1.0' -paths: - /welcome: - get: - operationId: fakeapi.module_with_exception.something - responses: - '200': - description: greeting response - content: - '*/*': - schema: - type: object -servers: - - url: /v1.1 +openapi: 3.0.0 +info: + title: '{{title}}' + version: '1.0' +paths: + /welcome: + get: + operationId: fakeapi.module_with_exception.something + responses: + '200': + description: greeting response + content: + '*/*': + schema: + type: object +servers: + - url: /v1.1 diff --git a/tests/fixtures/user_module_loading_error/swagger.yaml b/tests/fixtures/user_module_loading_error/swagger.yaml index e3930a5..25fa501 100644 --- a/tests/fixtures/user_module_loading_error/swagger.yaml +++ b/tests/fixtures/user_module_loading_error/swagger.yaml @@ -1,17 +1,17 @@ -swagger: "2.0" - -info: - title: "{{title}}" - version: "1.0" - -basePath: /v1.1 - -paths: - /welcome: - get: - operationId: fakeapi.module_with_exception.something - responses: - 200: - description: greeting response - schema: - type: object +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /v1.1 + +paths: + /welcome: + get: + operationId: fakeapi.module_with_exception.something + responses: + 200: + description: greeting response + schema: + type: object diff --git a/tests/test_api.py b/tests/test_api.py index 5596191..3a9e9fd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,138 +1,134 @@ -import os -import pathlib -import tempfile -from unittest.mock import MagicMock - -import pytest -from firetail import FlaskApi -from firetail.exceptions import InvalidSpecification, ResolverError -from firetail.spec import canonical_base_path -from yaml import YAMLError - -TEST_FOLDER = pathlib.Path(__file__).parent - - -def test_canonical_base_path(): - assert canonical_base_path('') == '' - assert canonical_base_path('/') == '' - assert canonical_base_path('/api') == '/api' - assert canonical_base_path('/api/') == '/api' - - -def test_api(): - api = FlaskApi(TEST_FOLDER / "fixtures/simple/swagger.yaml", - base_path="/api/v1.0") - assert api.blueprint.name == '/api/v1_0' - assert api.blueprint.url_prefix == '/api/v1.0' - - api2 = FlaskApi(TEST_FOLDER / "fixtures/simple/swagger.yaml") - assert api2.blueprint.name == '/v1_0' - assert api2.blueprint.url_prefix == '/v1.0' - - api3 = FlaskApi(TEST_FOLDER / "fixtures/simple/openapi.yaml", - base_path="/api/v1.0") - assert api3.blueprint.name == '/api/v1_0' - assert api3.blueprint.url_prefix == '/api/v1.0' - - api4 = FlaskApi(TEST_FOLDER / "fixtures/simple/openapi.yaml") - assert api4.blueprint.name == '/v1_0' - assert api4.blueprint.url_prefix == '/v1.0' - - -def test_api_base_path_slash(): - api = FlaskApi(TEST_FOLDER / "fixtures/simple/basepath-slash.yaml") - assert api.blueprint.name == '' - assert api.blueprint.url_prefix == '' - - -def test_template(): - api1 = FlaskApi(TEST_FOLDER / "fixtures/simple/swagger.yaml", - base_path="/api/v1.0", arguments={'title': 'test'}) - assert api1.specification['info']['title'] == 'test' - - api2 = FlaskApi(TEST_FOLDER / "fixtures/simple/swagger.yaml", - base_path="/api/v1.0", arguments={'title': 'other test'}) - assert api2.specification['info']['title'] == 'other test' - - -def test_invalid_operation_does_stop_application_to_setup(): - with pytest.raises(ImportError): - FlaskApi(TEST_FOLDER / "fixtures/op_error_api/swagger.yaml", - base_path="/api/v1.0", arguments={'title': 'OK'}) - - with pytest.raises(ValueError): - FlaskApi(TEST_FOLDER / "fixtures/missing_op_id/swagger.yaml", - base_path="/api/v1.0", arguments={'title': 'OK'}) - - with pytest.raises(ImportError): - FlaskApi(TEST_FOLDER / "fixtures/module_not_implemented/swagger.yaml", - base_path="/api/v1.0", arguments={'title': 'OK'}) - - with pytest.raises(ValueError): - FlaskApi(TEST_FOLDER / "fixtures/user_module_loading_error/swagger.yaml", - base_path="/api/v1.0", arguments={'title': 'OK'}) - - -def test_invalid_operation_does_not_stop_application_in_debug_mode(): - api = FlaskApi(TEST_FOLDER / "fixtures/op_error_api/swagger.yaml", - base_path="/api/v1.0", arguments={'title': 'OK'}, debug=True) - assert api.specification['info']['title'] == 'OK' - - api = FlaskApi(TEST_FOLDER / "fixtures/missing_op_id/swagger.yaml", - base_path="/api/v1.0", arguments={'title': 'OK'}, debug=True) - assert api.specification['info']['title'] == 'OK' - - api = FlaskApi(TEST_FOLDER / "fixtures/module_not_implemented/swagger.yaml", - base_path="/api/v1.0", arguments={'title': 'OK'}, debug=True) - assert api.specification['info']['title'] == 'OK' - - api = FlaskApi(TEST_FOLDER / "fixtures/user_module_loading_error/swagger.yaml", - base_path="/api/v1.0", arguments={'title': 'OK'}, debug=True) - assert api.specification['info']['title'] == 'OK' - - -def test_other_errors_stop_application_to_setup(): - # Errors should still result exceptions! - with pytest.raises(InvalidSpecification): - FlaskApi(TEST_FOLDER / "fixtures/bad_specs/swagger.yaml", - base_path="/api/v1.0", arguments={'title': 'OK'}) - - -def test_invalid_schema_file_structure(): - with pytest.raises(InvalidSpecification): - FlaskApi(TEST_FOLDER / "fixtures/invalid_schema/swagger.yaml", - base_path="/api/v1.0", arguments={'title': 'OK'}, debug=True) - - -def test_invalid_encoding(): - with tempfile.NamedTemporaryFile(mode='wb', delete=False) as f: - f.write( - "swagger: '2.0'\ninfo:\n title: Foo æ•´\n version: v1\npaths: {}".encode('gbk')) - FlaskApi(pathlib.Path(f.name), base_path="/api/v1.0") - os.unlink(f.name) - - -def test_use_of_safe_load_for_yaml_swagger_specs(): - with pytest.raises(YAMLError): - with tempfile.NamedTemporaryFile(delete=False) as f: - f.write(b'!!python/object:object {}\n') - try: - FlaskApi(pathlib.Path(f.name), base_path="/api/v1.0") - os.unlink(f.name) - except InvalidSpecification: - pytest.fail("Could load invalid YAML file, use yaml.safe_load!") - - -def test_validation_error_on_completely_invalid_swagger_spec(): - with tempfile.NamedTemporaryFile(delete=False) as f: - f.write(b'[1]\n') - with pytest.raises(InvalidSpecification): - FlaskApi(pathlib.Path(f.name), base_path="/api/v1.0") - os.unlink(f.name) - - -@pytest.fixture -def mock_api_logger(monkeypatch): - mocked_logger = MagicMock(name='mocked_logger') - monkeypatch.setattr('firetail.apis.abstract.logger', mocked_logger) - return mocked_logger +import os +import pathlib +import tempfile +from unittest.mock import MagicMock + +import pytest +from firetail import FlaskApi +from firetail.exceptions import InvalidSpecification, ResolverError +from firetail.spec import canonical_base_path +from yaml import YAMLError + +TEST_FOLDER = pathlib.Path(__file__).parent + + +def test_canonical_base_path(): + assert canonical_base_path('') == '' + assert canonical_base_path('/') == '' + assert canonical_base_path('/api') == '/api' + assert canonical_base_path('/api/') == '/api' + + +def test_api(): + api = FlaskApi(TEST_FOLDER / "fixtures/simple/swagger.yaml", base_path="/api/v1.0") + assert api.blueprint.name == '/api/v1_0' + assert api.blueprint.url_prefix == '/api/v1.0' + + api2 = FlaskApi(TEST_FOLDER / "fixtures/simple/swagger.yaml") + assert api2.blueprint.name == '/v1_0' + assert api2.blueprint.url_prefix == '/v1.0' + + api3 = FlaskApi(TEST_FOLDER / "fixtures/simple/openapi.yaml", base_path="/api/v1.0") + assert api3.blueprint.name == '/api/v1_0' + assert api3.blueprint.url_prefix == '/api/v1.0' + + api4 = FlaskApi(TEST_FOLDER / "fixtures/simple/openapi.yaml") + assert api4.blueprint.name == '/v1_0' + assert api4.blueprint.url_prefix == '/v1.0' + + +def test_api_base_path_slash(): + api = FlaskApi(TEST_FOLDER / "fixtures/simple/basepath-slash.yaml") + assert api.blueprint.name == '' + assert api.blueprint.url_prefix == '' + + +def test_template(): + api1 = FlaskApi(TEST_FOLDER / "fixtures/simple/swagger.yaml", + base_path="/api/v1.0", arguments={'title': 'test'}) + assert api1.specification['info']['title'] == 'test' + + api2 = FlaskApi(TEST_FOLDER / "fixtures/simple/swagger.yaml", + base_path="/api/v1.0", arguments={'title': 'other test'}) + assert api2.specification['info']['title'] == 'other test' + + +def test_invalid_operation_does_stop_application_to_setup(): + with pytest.raises(ImportError): + FlaskApi(TEST_FOLDER / "fixtures/op_error_api/swagger.yaml", + base_path="/api/v1.0", arguments={'title': 'OK'}) + + with pytest.raises(ValueError): + FlaskApi(TEST_FOLDER / "fixtures/missing_op_id/swagger.yaml", + base_path="/api/v1.0", arguments={'title': 'OK'}) + + with pytest.raises(ImportError): + FlaskApi(TEST_FOLDER / "fixtures/module_not_implemented/swagger.yaml", + base_path="/api/v1.0", arguments={'title': 'OK'}) + + with pytest.raises(ValueError): + FlaskApi(TEST_FOLDER / "fixtures/user_module_loading_error/swagger.yaml", + base_path="/api/v1.0", arguments={'title': 'OK'}) + + +def test_invalid_operation_does_not_stop_application_in_debug_mode(): + api = FlaskApi(TEST_FOLDER / "fixtures/op_error_api/swagger.yaml", + base_path="/api/v1.0", arguments={'title': 'OK'}, debug=True) + assert api.specification['info']['title'] == 'OK' + + api = FlaskApi(TEST_FOLDER / "fixtures/missing_op_id/swagger.yaml", + base_path="/api/v1.0", arguments={'title': 'OK'}, debug=True) + assert api.specification['info']['title'] == 'OK' + + api = FlaskApi(TEST_FOLDER / "fixtures/module_not_implemented/swagger.yaml", + base_path="/api/v1.0", arguments={'title': 'OK'}, debug=True) + assert api.specification['info']['title'] == 'OK' + + api = FlaskApi(TEST_FOLDER / "fixtures/user_module_loading_error/swagger.yaml", + base_path="/api/v1.0", arguments={'title': 'OK'}, debug=True) + assert api.specification['info']['title'] == 'OK' + + +def test_other_errors_stop_application_to_setup(): + # Errors should still result exceptions! + with pytest.raises(InvalidSpecification): + FlaskApi(TEST_FOLDER / "fixtures/bad_specs/swagger.yaml", + base_path="/api/v1.0", arguments={'title': 'OK'}) + + +def test_invalid_schema_file_structure(): + with pytest.raises(InvalidSpecification): + FlaskApi(TEST_FOLDER / "fixtures/invalid_schema/swagger.yaml", + base_path="/api/v1.0", arguments={'title': 'OK'}, debug=True) + + +def test_invalid_encoding(): + with tempfile.NamedTemporaryFile(mode='wb', delete=False) as f: + f.write("swagger: '2.0'\ninfo:\n title: Foo æ•´\n version: v1\npaths: {}".encode('gbk')) + FlaskApi(pathlib.Path(f.name), base_path="/api/v1.0") + os.unlink(f.name) + + +def test_use_of_safe_load_for_yaml_swagger_specs(): + with pytest.raises(YAMLError): + with tempfile.NamedTemporaryFile(delete=False) as f: + f.write(b'!!python/object:object {}\n') + try: + FlaskApi(pathlib.Path(f.name), base_path="/api/v1.0") + os.unlink(f.name) + except InvalidSpecification: + pytest.fail("Could load invalid YAML file, use yaml.safe_load!") + + +def test_validation_error_on_completely_invalid_swagger_spec(): + with tempfile.NamedTemporaryFile(delete=False) as f: + f.write(b'[1]\n') + with pytest.raises(InvalidSpecification): + FlaskApi(pathlib.Path(f.name), base_path="/api/v1.0") + os.unlink(f.name) + +@pytest.fixture +def mock_api_logger(monkeypatch): + mocked_logger = MagicMock(name='mocked_logger') + monkeypatch.setattr('firetail.apis.abstract.logger', mocked_logger) + return mocked_logger diff --git a/tests/test_app.py b/tests/test_app.py index a6589d2..b8e605b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,19 +1,19 @@ -from firetail.apis.flask_api import FlaskApi -from firetail.apps.flask_app import FlaskApp -from flask import Flask - - -def test_flask_app_default_params(): - app = FlaskApp('MyApp') - assert app.import_name == 'MyApp' - assert app.server == 'flask' - assert app.api_cls == FlaskApi - assert app.arguments == {} - # debug should be None so that user can use Flask environment variables to set it - assert app.debug is None - assert app.host is None - assert app.port is None - assert app.resolver is None - assert app.resolver_error is None - assert not app.auth_all_paths - assert type(app.app) == Flask +from firetail.apis.flask_api import FlaskApi +from firetail.apps.flask_app import FlaskApp +from flask import Flask + + +def test_flask_app_default_params(): + app = FlaskApp('MyApp') + assert app.import_name == 'MyApp' + assert app.server == 'flask' + assert app.api_cls == FlaskApi + assert app.arguments == {} + # debug should be None so that user can use Flask environment variables to set it + assert app.debug is None + assert app.host is None + assert app.port is None + assert app.resolver is None + assert app.resolver_error is None + assert not app.auth_all_paths + assert type(app.app) == Flask diff --git a/tests/test_cli.py b/tests/test_cli.py index f52a78e..88ec039 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,273 +1,308 @@ -import logging -from unittest.mock import MagicMock - -import firetail -import pytest -from click.testing import CliRunner -from firetail.cli import main - -from conftest import FIXTURES_FOLDER - - -@pytest.fixture() -def mock_app_run(mock_get_function_from_name): - test_server = MagicMock(wraps=firetail.FlaskApp(__name__)) - test_server.run = MagicMock(return_value=True) - test_app = MagicMock(return_value=test_server) - mock_get_function_from_name.return_value = test_app - return test_app - - -@pytest.fixture() -def mock_get_function_from_name(monkeypatch): - get_function_from_name = MagicMock() - monkeypatch.setattr( - 'firetail.cli.firetail.utils.get_function_from_name', - get_function_from_name - ) - return get_function_from_name - - -@pytest.fixture() -def expected_arguments(): - """ - Default values arguments used to call `firetail.App` by cli. - """ - return { - "options": { - "serve_spec": True, - "swagger_ui": True, - "swagger_path": None, - "swagger_url": None, - }, - "auth_all_paths": False, - "debug": False - } - - -@pytest.fixture() -def spec_file(): - return str(FIXTURES_FOLDER / 'simple/swagger.yaml') - - -def test_print_version(): - runner = CliRunner() - result = runner.invoke(main, ['--version'], catch_exceptions=False) - assert f"Firetail {firetail.__version__}" in result.output - - -def test_run_missing_spec(): - runner = CliRunner() - result = runner.invoke(main, ['run'], catch_exceptions=False) - assert "Missing argument" in result.output - - -def test_run_simple_spec(mock_app_run, spec_file): - default_port = 5000 - runner = CliRunner() - runner.invoke(main, ['run', spec_file], catch_exceptions=False) - - app_instance = mock_app_run() - app_instance.run.assert_called_with( - port=default_port, - host=None, - server='flask', - debug=False) - - -def test_run_spec_with_host(mock_app_run, spec_file): - default_port = 5000 - runner = CliRunner() - runner.invoke(main, ['run', spec_file, '--host', - 'custom.host'], catch_exceptions=False) - - app_instance = mock_app_run() - app_instance.run.assert_called_with( - port=default_port, - host='custom.host', - server='flask', - debug=False) - - -def test_run_no_options_all_default(mock_app_run, expected_arguments, spec_file): - runner = CliRunner() - runner.invoke(main, ['run', spec_file], catch_exceptions=False) - mock_app_run.assert_called_with('firetail.cli', **expected_arguments) - - -def test_run_using_option_hide_spec(mock_app_run, expected_arguments, - spec_file): - runner = CliRunner() - runner.invoke(main, ['run', spec_file, '--hide-spec'], - catch_exceptions=False) - - expected_arguments['options']['serve_spec'] = False - mock_app_run.assert_called_with('firetail.cli', **expected_arguments) - - -def test_run_using_option_hide_console_ui(mock_app_run, expected_arguments, - spec_file): - runner = CliRunner() - runner.invoke(main, ['run', spec_file, '--hide-console-ui'], - catch_exceptions=False) - - expected_arguments['options']['swagger_ui'] = False - mock_app_run.assert_called_with('firetail.cli', **expected_arguments) - - -def test_run_using_option_console_ui_from(mock_app_run, expected_arguments, - spec_file): - user_path = '/some/path/here' - runner = CliRunner() - runner.invoke(main, ['run', spec_file, '--console-ui-from', user_path], - catch_exceptions=False) - - expected_arguments['options']['swagger_path'] = user_path - mock_app_run.assert_called_with('firetail.cli', **expected_arguments) - - -def test_run_using_option_console_ui_url(mock_app_run, expected_arguments, - spec_file): - user_url = '/console_ui_test' - runner = CliRunner() - runner.invoke(main, ['run', spec_file, '--console-ui-url', user_url], - catch_exceptions=False) - - expected_arguments['options']['swagger_url'] = user_url - mock_app_run.assert_called_with('firetail.cli', **expected_arguments) - - -def test_run_using_option_auth_all_paths(mock_app_run, expected_arguments, - spec_file): - runner = CliRunner() - runner.invoke(main, ['run', spec_file, '--auth-all-paths'], - catch_exceptions=False) - - expected_arguments['auth_all_paths'] = True - mock_app_run.assert_called_with('firetail.cli', **expected_arguments) - - -def test_run_in_debug_mode(mock_app_run, expected_arguments, spec_file, - monkeypatch): - logging_config = MagicMock(name='firetail.cli.logging.basicConfig') - monkeypatch.setattr('firetail.cli.logging.basicConfig', - logging_config) - - runner = CliRunner() - runner.invoke(main, ['run', spec_file, '-d'], catch_exceptions=False) - - logging_config.assert_called_with(level=logging.DEBUG) - - expected_arguments['debug'] = True - mock_app_run.assert_called_with('firetail.cli', **expected_arguments) - - -def test_run_in_very_verbose_mode(mock_app_run, expected_arguments, spec_file, - monkeypatch): - logging_config = MagicMock(name='firetail.cli.logging.basicConfig') - monkeypatch.setattr('firetail.cli.logging.basicConfig', - logging_config) - - runner = CliRunner() - runner.invoke(main, ['run', spec_file, '-vv'], catch_exceptions=False) - - logging_config.assert_called_with(level=logging.DEBUG) - - expected_arguments['debug'] = True - mock_app_run.assert_called_with('firetail.cli', **expected_arguments) - - -def test_run_in_verbose_mode(mock_app_run, expected_arguments, spec_file, - monkeypatch): - logging_config = MagicMock(name='firetail.cli.logging.basicConfig') - monkeypatch.setattr('firetail.cli.logging.basicConfig', - logging_config) - - runner = CliRunner() - runner.invoke(main, ['run', spec_file, '-v'], catch_exceptions=False) - - logging_config.assert_called_with(level=logging.INFO) - - expected_arguments['debug'] = False - mock_app_run.assert_called_with('firetail.cli', **expected_arguments) - - -def test_run_using_option_base_path(mock_app_run, expected_arguments, - spec_file): - runner = CliRunner() - runner.invoke(main, ['run', spec_file, '--base-path', '/foo'], - catch_exceptions=False) - - expected_arguments = dict(base_path='/foo', - resolver_error=None, - validate_responses=False, - strict_validation=False) - mock_app_run().add_api.assert_called_with(spec_file, **expected_arguments) - - -def test_run_unimplemented_operations_and_stub(mock_app_run): - runner = CliRunner() - - spec_file = str(FIXTURES_FOLDER / 'missing_implementation/swagger.yaml') - with pytest.raises(AttributeError): - runner.invoke(main, ['run', spec_file], catch_exceptions=False) - # yet can be run with --stub option - result = runner.invoke( - main, ['run', spec_file, '--stub'], catch_exceptions=False) - assert result.exit_code == 0 - - spec_file = str(FIXTURES_FOLDER / 'module_does_not_exist/swagger.yaml') - with pytest.raises(ImportError): - runner.invoke(main, ['run', spec_file], catch_exceptions=False) - # yet can be run with --stub option - result = runner.invoke( - main, ['run', spec_file, '--stub'], catch_exceptions=False) - assert result.exit_code == 0 - - -def test_run_unimplemented_operations_and_mock(mock_app_run): - runner = CliRunner() - - spec_file = str(FIXTURES_FOLDER / 'missing_implementation/swagger.yaml') - with pytest.raises(AttributeError): - runner.invoke(main, ['run', spec_file], catch_exceptions=False) - # yet can be run with --mock option - result = runner.invoke( - main, ['run', spec_file, '--mock=all'], catch_exceptions=False) - assert result.exit_code == 0 - - -def test_run_with_wsgi_containers(mock_app_run, spec_file): - runner = CliRunner() - - # missing gevent - result = runner.invoke(main, - ['run', spec_file, '-w', 'gevent'], - catch_exceptions=False) - assert 'gevent library is not installed' in result.output - assert result.exit_code == 1 - - # missing tornado - result = runner.invoke(main, - ['run', spec_file, '-w', 'tornado'], - catch_exceptions=False) - assert 'tornado library is not installed' in result.output - assert result.exit_code == 1 - - # using flask - result = runner.invoke(main, - ['run', spec_file, '-w', 'flask'], - catch_exceptions=False) - assert result.exit_code == 0 - - -def test_run_with_wsgi_server_and_server_opts(mock_app_run, spec_file): - runner = CliRunner() - - result = runner.invoke(main, - ['run', spec_file, - '-w', 'flask', - '-s', 'flask'], - catch_exceptions=False) - assert "these options are mutually exclusive" in result.output - assert result.exit_code == 2 +import logging +from unittest.mock import MagicMock + +import firetail +import pytest +from click.testing import CliRunner +from conftest import FIXTURES_FOLDER +from firetail.cli import main + + +@pytest.fixture() +def mock_app_run(mock_get_function_from_name): + test_server = MagicMock(wraps=firetail.FlaskApp(__name__)) + test_server.run = MagicMock(return_value=True) + test_app = MagicMock(return_value=test_server) + mock_get_function_from_name.return_value = test_app + return test_app + + +@pytest.fixture() +def mock_get_function_from_name(monkeypatch): + get_function_from_name = MagicMock() + monkeypatch.setattr( + 'firetail.cli.firetail.utils.get_function_from_name', + get_function_from_name + ) + return get_function_from_name + + +@pytest.fixture() +def expected_arguments(): + """ + Default values arguments used to call `firetail.App` by cli. + """ + return { + "options": { + "serve_spec": True, + "swagger_ui": True, + "swagger_path": None, + "swagger_url": None, + }, + "auth_all_paths": False, + "debug": False + } + + +@pytest.fixture() +def spec_file(): + return str(FIXTURES_FOLDER / 'simple/swagger.yaml') + + +def test_print_version(): + runner = CliRunner() + result = runner.invoke(main, ['--version'], catch_exceptions=False) + assert f"Firetail {firetail.__version__}" in result.output + + +def test_run_missing_spec(): + runner = CliRunner() + result = runner.invoke(main, ['run'], catch_exceptions=False) + assert "Missing argument" in result.output + + +def test_run_simple_spec(mock_app_run, spec_file): + default_port = 5000 + runner = CliRunner() + runner.invoke(main, ['run', spec_file], catch_exceptions=False) + + app_instance = mock_app_run() + app_instance.run.assert_called_with( + port=default_port, + host=None, + server='flask', + debug=False) + + +def test_run_spec_with_host(mock_app_run, spec_file): + default_port = 5000 + runner = CliRunner() + runner.invoke(main, ['run', spec_file, '--host', 'custom.host'], catch_exceptions=False) + + app_instance = mock_app_run() + app_instance.run.assert_called_with( + port=default_port, + host='custom.host', + server='flask', + debug=False) + + +def test_run_no_options_all_default(mock_app_run, expected_arguments, spec_file): + runner = CliRunner() + runner.invoke(main, ['run', spec_file], catch_exceptions=False) + mock_app_run.assert_called_with('firetail.cli', **expected_arguments) + + +def test_run_using_option_hide_spec(mock_app_run, expected_arguments, + spec_file): + runner = CliRunner() + runner.invoke(main, ['run', spec_file, '--hide-spec'], + catch_exceptions=False) + + expected_arguments['options']['serve_spec'] = False + mock_app_run.assert_called_with('firetail.cli', **expected_arguments) + + +def test_run_using_option_hide_console_ui(mock_app_run, expected_arguments, + spec_file): + runner = CliRunner() + runner.invoke(main, ['run', spec_file, '--hide-console-ui'], + catch_exceptions=False) + + expected_arguments['options']['swagger_ui'] = False + mock_app_run.assert_called_with('firetail.cli', **expected_arguments) + + +def test_run_using_option_console_ui_from(mock_app_run, expected_arguments, + spec_file): + user_path = '/some/path/here' + runner = CliRunner() + runner.invoke(main, ['run', spec_file, '--console-ui-from', user_path], + catch_exceptions=False) + + expected_arguments['options']['swagger_path'] = user_path + mock_app_run.assert_called_with('firetail.cli', **expected_arguments) + + +def test_run_using_option_console_ui_url(mock_app_run, expected_arguments, + spec_file): + user_url = '/console_ui_test' + runner = CliRunner() + runner.invoke(main, ['run', spec_file, '--console-ui-url', user_url], + catch_exceptions=False) + + expected_arguments['options']['swagger_url'] = user_url + mock_app_run.assert_called_with('firetail.cli', **expected_arguments) + + +def test_run_using_option_auth_all_paths(mock_app_run, expected_arguments, + spec_file): + runner = CliRunner() + runner.invoke(main, ['run', spec_file, '--auth-all-paths'], + catch_exceptions=False) + + expected_arguments['auth_all_paths'] = True + mock_app_run.assert_called_with('firetail.cli', **expected_arguments) + + +def test_run_in_debug_mode(mock_app_run, expected_arguments, spec_file, + monkeypatch): + logging_config = MagicMock(name='firetail.cli.logging.basicConfig') + monkeypatch.setattr('firetail.cli.logging.basicConfig', + logging_config) + + runner = CliRunner() + runner.invoke(main, ['run', spec_file, '-d'], catch_exceptions=False) + + logging_config.assert_called_with(level=logging.DEBUG) + + expected_arguments['debug'] = True + mock_app_run.assert_called_with('firetail.cli', **expected_arguments) + + +def test_run_in_very_verbose_mode(mock_app_run, expected_arguments, spec_file, + monkeypatch): + logging_config = MagicMock(name='firetail.cli.logging.basicConfig') + monkeypatch.setattr('firetail.cli.logging.basicConfig', + logging_config) + + runner = CliRunner() + runner.invoke(main, ['run', spec_file, '-vv'], catch_exceptions=False) + + logging_config.assert_called_with(level=logging.DEBUG) + + expected_arguments['debug'] = True + mock_app_run.assert_called_with('firetail.cli', **expected_arguments) + + +def test_run_in_verbose_mode(mock_app_run, expected_arguments, spec_file, + monkeypatch): + logging_config = MagicMock(name='firetail.cli.logging.basicConfig') + monkeypatch.setattr('firetail.cli.logging.basicConfig', + logging_config) + + runner = CliRunner() + runner.invoke(main, ['run', spec_file, '-v'], catch_exceptions=False) + + logging_config.assert_called_with(level=logging.INFO) + + expected_arguments['debug'] = False + mock_app_run.assert_called_with('firetail.cli', **expected_arguments) + + +def test_run_using_option_base_path(mock_app_run, expected_arguments, + spec_file): + runner = CliRunner() + runner.invoke(main, ['run', spec_file, '--base-path', '/foo'], + catch_exceptions=False) + + expected_arguments = dict(base_path='/foo', + resolver_error=None, + validate_responses=False, + strict_validation=False) + mock_app_run().add_api.assert_called_with(spec_file, **expected_arguments) + + +def test_run_unimplemented_operations_and_stub(mock_app_run): + runner = CliRunner() + + spec_file = str(FIXTURES_FOLDER / 'missing_implementation/swagger.yaml') + with pytest.raises(AttributeError): + runner.invoke(main, ['run', spec_file], catch_exceptions=False) + # yet can be run with --stub option + result = runner.invoke(main, ['run', spec_file, '--stub'], catch_exceptions=False) + assert result.exit_code == 0 + + spec_file = str(FIXTURES_FOLDER / 'module_does_not_exist/swagger.yaml') + with pytest.raises(ImportError): + runner.invoke(main, ['run', spec_file], catch_exceptions=False) + # yet can be run with --stub option + result = runner.invoke(main, ['run', spec_file, '--stub'], catch_exceptions=False) + assert result.exit_code == 0 + + +def test_run_unimplemented_operations_and_mock(mock_app_run): + runner = CliRunner() + + spec_file = str(FIXTURES_FOLDER / 'missing_implementation/swagger.yaml') + with pytest.raises(AttributeError): + runner.invoke(main, ['run', spec_file], catch_exceptions=False) + # yet can be run with --mock option + result = runner.invoke(main, ['run', spec_file, '--mock=all'], catch_exceptions=False) + assert result.exit_code == 0 + + +def test_run_with_wsgi_containers(mock_app_run, spec_file): + runner = CliRunner() + + # missing gevent + result = runner.invoke(main, + ['run', spec_file, '-w', 'gevent'], + catch_exceptions=False) + assert 'gevent library is not installed' in result.output + assert result.exit_code == 1 + + # missing tornado + result = runner.invoke(main, + ['run', spec_file, '-w', 'tornado'], + catch_exceptions=False) + assert 'tornado library is not installed' in result.output + assert result.exit_code == 1 + + # using flask + result = runner.invoke(main, + ['run', spec_file, '-w', 'flask'], + catch_exceptions=False) + assert result.exit_code == 0 + + +def test_run_with_aiohttp_not_installed(mock_app_run, spec_file): + import sys + aiohttp_bkp = sys.modules.pop('aiohttp', None) + sys.modules['aiohttp'] = None + + runner = CliRunner() + + # missing aiohttp + result = runner.invoke(main, + ['run', spec_file, '-f', 'aiohttp'], + catch_exceptions=False) + sys.modules['aiohttp'] = aiohttp_bkp + + assert 'aiohttp library is not installed' in result.output + assert result.exit_code == 1 + + +def test_run_with_wsgi_server_and_server_opts(mock_app_run, spec_file): + runner = CliRunner() + + result = runner.invoke(main, + ['run', spec_file, + '-w', 'flask', + '-s', 'flask'], + catch_exceptions=False) + assert "these options are mutually exclusive" in result.output + assert result.exit_code == 2 + + +def test_run_with_incompatible_server_and_default_framework(mock_app_run, spec_file): + runner = CliRunner() + + result = runner.invoke(main, + ['run', spec_file, + '-s', 'aiohttp'], + catch_exceptions=False) + assert "Invalid server 'aiohttp' for app-framework 'flask'" in result.output + assert result.exit_code == 2 + + +def test_run_with_incompatible_server_and_framework(mock_app_run, spec_file): + runner = CliRunner() + + result = runner.invoke(main, + ['run', spec_file, + '-s', 'flask', + '-f', 'aiohttp'], + catch_exceptions=False) + assert "Invalid server 'flask' for app-framework 'aiohttp'" in result.output + assert result.exit_code == 2 diff --git a/tests/test_flask_encoder.py b/tests/test_flask_encoder.py index ee12921..68b3c24 100644 --- a/tests/test_flask_encoder.py +++ b/tests/test_flask_encoder.py @@ -1,93 +1,87 @@ -import datetime -import json -import math -from decimal import Decimal - -import pytest -from firetail.apps.flask_app import FlaskJSONEncoder - -from conftest import build_app_from_fixture - -SPECS = ["swagger.yaml", "openapi.yaml"] - - -def test_json_encoder(): - s = json.dumps({1: 2}, cls=FlaskJSONEncoder) - assert '{"1": 2}' == s - - s = json.dumps(datetime.date.today(), cls=FlaskJSONEncoder) - assert len(s) == 12 - - s = json.dumps(datetime.datetime.utcnow(), cls=FlaskJSONEncoder) - assert s.endswith('Z"') - - s = json.dumps(Decimal(1.01), cls=FlaskJSONEncoder) - assert s == '1.01' - - s = json.dumps(math.expm1(1e-10), cls=FlaskJSONEncoder) - assert s == '1.00000000005e-10' - - -def test_json_encoder_datetime_with_timezone(): - - class DummyTimezone(datetime.tzinfo): - - def utcoffset(self, dt): - return datetime.timedelta(0) - - def dst(self, dt): - return datetime.timedelta(0) - - s = json.dumps(datetime.datetime.now( - DummyTimezone()), cls=FlaskJSONEncoder) - assert s.endswith('+00:00"') - - -@pytest.mark.parametrize("spec", SPECS) -def test_readonly(json_datetime_dir, spec): - app = build_app_from_fixture( - json_datetime_dir, spec, validate_responses=True) - app_client = app.app.test_client() - - res = app_client.get('/v1.0/' + spec.replace('yaml', 'json')) - assert res.status_code == 200, f"Error is {res.data}" - spec_data = json.loads(res.data.decode()) - - if spec == 'openapi.yaml': - response_path = 'responses.200.content.application/json.schema' - else: - response_path = 'responses.200.schema' - - def get_value(data, path): - for part in path.split('.'): - data = data.get(part) - assert data, f"No data in part '{part}' of '{path}'" - return data - - example = get_value( - spec_data, f'paths./datetime.get.{response_path}.example.value') - assert example in [ - '2000-01-23T04:56:07.000008+00:00', # PyYAML 5.3+ - '2000-01-23T04:56:07.000008Z' - ] - example = get_value( - spec_data, f'paths./date.get.{response_path}.example.value') - assert example == '2000-01-23' - example = get_value( - spec_data, f'paths./uuid.get.{response_path}.example.value') - assert example == 'a7b8869c-5f24-4ce0-a5d1-3e44c3663aa9' - - res = app_client.get('/v1.0/datetime') - assert res.status_code == 200, f"Error is {res.data}" - data = json.loads(res.data.decode()) - assert data == {'value': '2000-01-02T03:04:05.000006Z'} - - res = app_client.get('/v1.0/date') - assert res.status_code == 200, f"Error is {res.data}" - data = json.loads(res.data.decode()) - assert data == {'value': '2000-01-02'} - - res = app_client.get('/v1.0/uuid') - assert res.status_code == 200, f"Error is {res.data}" - data = json.loads(res.data.decode()) - assert data == {'value': 'e7ff66d0-3ec2-4c4e-bed0-6e4723c24c51'} +import datetime +import json +import math +from decimal import Decimal + +import pytest +from conftest import build_app_from_fixture +from firetail.apps.flask_app import FlaskJSONEncoder + +SPECS = ["swagger.yaml", "openapi.yaml"] + + +def test_json_encoder(): + s = json.dumps({1: 2}, cls=FlaskJSONEncoder) + assert '{"1": 2}' == s + + s = json.dumps(datetime.date.today(), cls=FlaskJSONEncoder) + assert len(s) == 12 + + s = json.dumps(datetime.datetime.utcnow(), cls=FlaskJSONEncoder) + assert s.endswith('Z"') + + s = json.dumps(Decimal(1.01), cls=FlaskJSONEncoder) + assert s == '1.01' + + s = json.dumps(math.expm1(1e-10), cls=FlaskJSONEncoder) + assert s == '1.00000000005e-10' + + +def test_json_encoder_datetime_with_timezone(): + + class DummyTimezone(datetime.tzinfo): + + def utcoffset(self, dt): + return datetime.timedelta(0) + + def dst(self, dt): + return datetime.timedelta(0) + + s = json.dumps(datetime.datetime.now(DummyTimezone()), cls=FlaskJSONEncoder) + assert s.endswith('+00:00"') + + +@pytest.mark.parametrize("spec", SPECS) +def test_readonly(json_datetime_dir, spec): + app = build_app_from_fixture(json_datetime_dir, spec, validate_responses=True) + app_client = app.app.test_client() + + res = app_client.get('/v1.0/' + spec.replace('yaml', 'json')) + assert res.status_code == 200, f"Error is {res.data}" + spec_data = json.loads(res.data.decode()) + + if spec == 'openapi.yaml': + response_path = 'responses.200.content.application/json.schema' + else: + response_path = 'responses.200.schema' + + def get_value(data, path): + for part in path.split('.'): + data = data.get(part) + assert data, f"No data in part '{part}' of '{path}'" + return data + + example = get_value(spec_data, f'paths./datetime.get.{response_path}.example.value') + assert example in [ + '2000-01-23T04:56:07.000008+00:00', # PyYAML 5.3+ + '2000-01-23T04:56:07.000008Z' + ] + example = get_value(spec_data, f'paths./date.get.{response_path}.example.value') + assert example == '2000-01-23' + example = get_value(spec_data, f'paths./uuid.get.{response_path}.example.value') + assert example == 'a7b8869c-5f24-4ce0-a5d1-3e44c3663aa9' + + res = app_client.get('/v1.0/datetime') + assert res.status_code == 200, f"Error is {res.data}" + data = json.loads(res.data.decode()) + assert data == {'value': '2000-01-02T03:04:05.000006Z'} + + res = app_client.get('/v1.0/date') + assert res.status_code == 200, f"Error is {res.data}" + data = json.loads(res.data.decode()) + assert data == {'value': '2000-01-02'} + + res = app_client.get('/v1.0/uuid') + assert res.status_code == 200, f"Error is {res.data}" + data = json.loads(res.data.decode()) + assert data == {'value': 'e7ff66d0-3ec2-4c4e-bed0-6e4723c24c51'} diff --git a/tests/test_flask_utils.py b/tests/test_flask_utils.py index 4bc1abd..4251306 100644 --- a/tests/test_flask_utils.py +++ b/tests/test_flask_utils.py @@ -1,30 +1,23 @@ -import firetail.apis.flask_utils as flask_utils - - -def test_flaskify_path(): - assert flask_utils.flaskify_path("{test-path}") == "" - assert flask_utils.flaskify_path("api/{test-path}") == "api/" - assert flask_utils.flaskify_path( - "my-api/{test-path}") == "my-api/" - assert flask_utils.flaskify_path( - "foo_bar/{a-b}/{c_d}") == "foo_bar//" - assert flask_utils.flaskify_path( - "foo/{a}/{b}", {'a': 'integer'}) == "foo//" - assert flask_utils.flaskify_path( - "foo/{a}/{b}", {'a': 'number'}) == "foo//" - assert flask_utils.flaskify_path( - "foo/{a}/{b}", {'a': 'path'}) == "foo//" - assert flask_utils.flaskify_path( - "foo/{a}", {'a': 'path'}) == "foo/" - - -def test_flaskify_endpoint(): - assert flask_utils.flaskify_endpoint( - "module.function") == "module_function" - assert flask_utils.flaskify_endpoint("function") == "function" - - name = 'module.function' - randlen = 6 - res = flask_utils.flaskify_endpoint(name, randlen) - assert res.startswith('module_function') - assert len(res) == len(name) + 1 + randlen +import firetail.apis.flask_utils as flask_utils + + +def test_flaskify_path(): + assert flask_utils.flaskify_path("{test-path}") == "" + assert flask_utils.flaskify_path("api/{test-path}") == "api/" + assert flask_utils.flaskify_path("my-api/{test-path}") == "my-api/" + assert flask_utils.flaskify_path("foo_bar/{a-b}/{c_d}") == "foo_bar//" + assert flask_utils.flaskify_path("foo/{a}/{b}", {'a': 'integer'}) == "foo//" + assert flask_utils.flaskify_path("foo/{a}/{b}", {'a': 'number'}) == "foo//" + assert flask_utils.flaskify_path("foo/{a}/{b}", {'a': 'path'}) == "foo//" + assert flask_utils.flaskify_path("foo/{a}", {'a': 'path'}) == "foo/" + + +def test_flaskify_endpoint(): + assert flask_utils.flaskify_endpoint("module.function") == "module_function" + assert flask_utils.flaskify_endpoint("function") == "function" + + name = 'module.function' + randlen = 6 + res = flask_utils.flaskify_endpoint(name, randlen) + assert res.startswith('module_function') + assert len(res) == len(name) + 1 + randlen diff --git a/tests/test_json_validation.py b/tests/test_json_validation.py index 21e8cb6..0f44675 100644 --- a/tests/test_json_validation.py +++ b/tests/test_json_validation.py @@ -1,108 +1,94 @@ -import json -import pathlib - -import pytest -from firetail import App -from firetail.decorators.validation import RequestBodyValidator -from firetail.json_schema import Draft4RequestValidator -from firetail.spec import Specification -from jsonschema.validators import _utils, extend - -from conftest import build_app_from_fixture - -SPECS = ["swagger.yaml", "openapi.yaml"] - - -@pytest.mark.parametrize("spec", SPECS) -def test_validator_map(json_validation_spec_dir, spec): - def validate_type(validator, types, instance, schema): - types = _utils.ensure_list(types) - errors = Draft4RequestValidator.VALIDATORS['type']( - validator, types, instance, schema) - yield from errors - - if 'string' in types and 'minLength' not in schema: - errors = Draft4RequestValidator.VALIDATORS['minLength']( - validator, 1, instance, schema) - yield from errors - - MinLengthRequestValidator = extend( - Draft4RequestValidator, {'type': validate_type}) - - class MyRequestBodyValidator(RequestBodyValidator): - def __init__(self, *args, **kwargs): - super().__init__(*args, validator=MinLengthRequestValidator, **kwargs) - - validator_map = {'body': MyRequestBodyValidator} - - app = App(__name__, specification_dir=json_validation_spec_dir) - app.add_api(spec, validate_responses=True, validator_map=validator_map) - app_client = app.app.test_client() - - res = app_client.post('/v1.0/minlength', data=json.dumps( - {'foo': 'bar'}), content_type='application/json') # type: flask.Response - assert res.status_code == 200 - - res = app_client.post('/v1.0/minlength', data=json.dumps( - {'foo': ''}), content_type='application/json') # type: flask.Response - assert res.status_code == 400 - - -@pytest.mark.parametrize("spec", SPECS) -def test_readonly(json_validation_spec_dir, spec): - app = build_app_from_fixture( - json_validation_spec_dir, spec, validate_responses=True) - app_client = app.app.test_client() - - res = app_client.get('/v1.0/user') # type: flask.Response - assert res.status_code == 200 - assert json.loads(res.data.decode()).get('user_id') == 7 - - res = app_client.post('/v1.0/user', data=json.dumps( - {'name': 'max', 'password': '1234'}), content_type='application/json') # type: flask.Response - assert res.status_code == 200 - assert json.loads(res.data.decode()).get('user_id') == 8 - - res = app_client.post('/v1.0/user', data=json.dumps( - {'user_id': 9, 'name': 'max'}), content_type='application/json') # type: flask.Response - assert res.status_code == 400 - - -@pytest.mark.parametrize("spec", SPECS) -def test_writeonly(json_validation_spec_dir, spec): - app = build_app_from_fixture( - json_validation_spec_dir, spec, validate_responses=True) - app_client = app.app.test_client() - - res = app_client.post('/v1.0/user', data=json.dumps( - {'name': 'max', 'password': '1234'}), content_type='application/json') # type: flask.Response - assert res.status_code == 200 - assert 'password' not in json.loads(res.data.decode()) - - res = app_client.get('/v1.0/user') # type: flask.Response - assert res.status_code == 200 - assert 'password' not in json.loads(res.data.decode()) - - res = app_client.get('/v1.0/user_with_password') # type: flask.Response - assert res.status_code == 500 - assert json.loads(res.data.decode())[ - 'title'] == 'Response body does not conform to specification' - - -@pytest.mark.parametrize("spec", SPECS) -def test_nullable_default(json_validation_spec_dir, spec): - spec_path = pathlib.Path(json_validation_spec_dir) / spec - Specification.load(spec_path) - - -@pytest.mark.parametrize("spec", ["openapi.yaml"]) -def test_multipart_form_json(json_validation_spec_dir, spec): - app = build_app_from_fixture( - json_validation_spec_dir, spec, validate_responses=True) - app_client = app.app.test_client() - - res = app_client.post('/v1.0/multipart_form_json', data={'x': json.dumps( - {"name": "joe", "age": 20})}, content_type='multipart/form-data') - assert res.status_code == 200 - assert json.loads(res.data.decode())['name'] == "joe-reply" - assert json.loads(res.data.decode())['age'] == 30 +import json +import pathlib + +import pytest +from conftest import build_app_from_fixture +from firetail import App +from firetail.decorators.validation import RequestBodyValidator +from firetail.json_schema import Draft4RequestValidator +from firetail.spec import Specification +from jsonschema.validators import _utils, extend + +SPECS = ["swagger.yaml", "openapi.yaml"] + + +@pytest.mark.parametrize("spec", SPECS) +def test_validator_map(json_validation_spec_dir, spec): + def validate_type(validator, types, instance, schema): + types = _utils.ensure_list(types) + errors = Draft4RequestValidator.VALIDATORS['type'](validator, types, instance, schema) + yield from errors + + if 'string' in types and 'minLength' not in schema: + errors = Draft4RequestValidator.VALIDATORS['minLength'](validator, 1, instance, schema) + yield from errors + + MinLengthRequestValidator = extend(Draft4RequestValidator, {'type': validate_type}) + + class MyRequestBodyValidator(RequestBodyValidator): + def __init__(self, *args, **kwargs): + super().__init__(*args, validator=MinLengthRequestValidator, **kwargs) + + validator_map = {'body': MyRequestBodyValidator} + + app = App(__name__, specification_dir=json_validation_spec_dir) + app.add_api(spec, validate_responses=True, validator_map=validator_map) + app_client = app.app.test_client() + + res = app_client.post('/v1.0/minlength', data=json.dumps({'foo': 'bar'}), content_type='application/json') # type: flask.Response + assert res.status_code == 200 + + res = app_client.post('/v1.0/minlength', data=json.dumps({'foo': ''}), content_type='application/json') # type: flask.Response + assert res.status_code == 400 + + +@pytest.mark.parametrize("spec", SPECS) +def test_readonly(json_validation_spec_dir, spec): + app = build_app_from_fixture(json_validation_spec_dir, spec, validate_responses=True) + app_client = app.app.test_client() + + res = app_client.get('/v1.0/user') # type: flask.Response + assert res.status_code == 200 + assert json.loads(res.data.decode()).get('user_id') == 7 + + res = app_client.post('/v1.0/user', data=json.dumps({'name': 'max', 'password': '1234'}), content_type='application/json') # type: flask.Response + assert res.status_code == 200 + assert json.loads(res.data.decode()).get('user_id') == 8 + + res = app_client.post('/v1.0/user', data=json.dumps({'user_id': 9, 'name': 'max'}), content_type='application/json') # type: flask.Response + assert res.status_code == 400 + + +@pytest.mark.parametrize("spec", SPECS) +def test_writeonly(json_validation_spec_dir, spec): + app = build_app_from_fixture(json_validation_spec_dir, spec, validate_responses=True) + app_client = app.app.test_client() + + res = app_client.post('/v1.0/user', data=json.dumps({'name': 'max', 'password': '1234'}), content_type='application/json') # type: flask.Response + assert res.status_code == 200 + assert 'password' not in json.loads(res.data.decode()) + + res = app_client.get('/v1.0/user') # type: flask.Response + assert res.status_code == 200 + assert 'password' not in json.loads(res.data.decode()) + + res = app_client.get('/v1.0/user_with_password') # type: flask.Response + assert res.status_code == 500 + assert json.loads(res.data.decode())['title'] == 'Response body does not conform to specification' + + +@pytest.mark.parametrize("spec", SPECS) +def test_nullable_default(json_validation_spec_dir, spec): + spec_path = pathlib.Path(json_validation_spec_dir) / spec + Specification.load(spec_path) + + +@pytest.mark.parametrize("spec", ["openapi.yaml"]) +def test_multipart_form_json(json_validation_spec_dir, spec): + app = build_app_from_fixture(json_validation_spec_dir, spec, validate_responses=True) + app_client = app.app.test_client() + + res = app_client.post('/v1.0/multipart_form_json', data={'x': json.dumps({"name": "joe", "age": 20})}, content_type='multipart/form-data') + assert res.status_code == 200 + assert json.loads(res.data.decode())['name'] == "joe-reply" + assert json.loads(res.data.decode())['age'] == 30 diff --git a/tests/test_metrics.py b/tests/test_metrics.py index e4d260d..be45a13 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -1,24 +1,23 @@ -from unittest.mock import MagicMock - -import flask -import pytest -from firetail.decorators.metrics import UWSGIMetricsCollector -from firetail.exceptions import ProblemException - - -def test_timer(monkeypatch): - wrapper = UWSGIMetricsCollector('/foo/bar/', 'get') - - def operation(req): - raise ProblemException(418, '', '') - - op = wrapper(operation) - metrics = MagicMock() - monkeypatch.setattr('flask.request', MagicMock()) - monkeypatch.setattr('flask.current_app', MagicMock( - response_class=flask.Response)) - monkeypatch.setattr('firetail.decorators.metrics.uwsgi_metrics', metrics) - with pytest.raises(ProblemException) as exc: - op(MagicMock()) - assert metrics.timer.call_args[0][:2] == ('firetail.response', - '418.GET.foo.bar.{param}') +from unittest.mock import MagicMock + +import flask +import pytest +from firetail.decorators.metrics import UWSGIMetricsCollector +from firetail.exceptions import ProblemException + + +def test_timer(monkeypatch): + wrapper = UWSGIMetricsCollector('/foo/bar/', 'get') + + def operation(req): + raise ProblemException(418, '', '') + + op = wrapper(operation) + metrics = MagicMock() + monkeypatch.setattr('flask.request', MagicMock()) + monkeypatch.setattr('flask.current_app', MagicMock(response_class=flask.Response)) + monkeypatch.setattr('firetail.decorators.metrics.uwsgi_metrics', metrics) + with pytest.raises(ProblemException) as exc: + op(MagicMock()) + assert metrics.timer.call_args[0][:2] == ('firetail.response', + '418.GET.foo.bar.{param}') diff --git a/tests/test_mock.py b/tests/test_mock.py index e66a219..a546bb6 100644 --- a/tests/test_mock.py +++ b/tests/test_mock.py @@ -1,401 +1,391 @@ -from firetail.mock import MockResolver -from firetail.operations import OpenAPIOperation, Swagger2Operation - - -def test_mock_resolver_default(): - resolver = MockResolver(mock_all=True) - - responses = { - 'default': { - 'examples': { - 'application/json': { - 'foo': 'bar' - } - } - } - } - - operation = Swagger2Operation(api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'responses': responses - }, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions={}, - resolver=resolver) - assert operation.operation_id == 'mock-1' - - response, status_code = resolver.mock_operation(operation) - assert status_code == 200 - assert response == {'foo': 'bar'} - - -def test_mock_resolver_numeric(): - resolver = MockResolver(mock_all=True) - - responses = { - '200': { - 'examples': { - 'application/json': { - 'foo': 'bar' - } - } - } - } - - operation = Swagger2Operation(api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'responses': responses - }, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions={}, - resolver=resolver) - assert operation.operation_id == 'mock-1' - - response, status_code = resolver.mock_operation(operation) - assert status_code == 200 - assert response == {'foo': 'bar'} - - -def test_mock_resolver_example(): - resolver = MockResolver(mock_all=True) - - responses = { - 'default': { - 'schema': { - 'type': 'object', - 'properties': { - 'foo': { - 'type': 'string' - } - }, - 'example': { - 'foo': 'bar' - } - } - } - } - - operation = Swagger2Operation(api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'responses': responses - }, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions={}, - resolver=resolver) - assert operation.operation_id == 'mock-1' - - response, status_code = resolver.mock_operation(operation) - assert status_code == 200 - assert response == {'foo': 'bar'} - - -def test_mock_resolver_example_nested_in_object(): - resolver = MockResolver(mock_all=True) - - responses = { - 'default': { - 'schema': { - 'type': 'object', - 'properties': { - 'foo': { - 'type': 'string', - 'example': 'bar' - } - }, - } - } - } - - operation = Swagger2Operation(api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'responses': responses - }, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions={}, - resolver=resolver) - assert operation.operation_id == 'mock-1' - - response, status_code = resolver.mock_operation(operation) - assert status_code == 200 - assert response == {'foo': 'bar'} - - -def test_mock_resolver_example_nested_in_list(): - resolver = MockResolver(mock_all=True) - - responses = { - 'default': { - 'schema': { - 'type': 'array', - 'items': { - 'type': 'string', - 'example': 'bar' - }, - } - } - } - - operation = Swagger2Operation(api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'responses': responses - }, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions={}, - resolver=resolver) - assert operation.operation_id == 'mock-1' - - response, status_code = resolver.mock_operation(operation) - assert status_code == 200 - assert response == ['bar'] - - -def test_mock_resolver_example_nested_in_object_openapi(): - resolver = MockResolver(mock_all=True) - - responses = { - 'default': { - 'content': { - 'application/json': { - 'schema': { - 'type': 'object', - 'properties': { - 'foo': { - 'type': 'string', - 'example': 'bar' - } - } - } - } - } - } - } - - operation = OpenAPIOperation(api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'responses': responses - }, - app_security=[], - resolver=resolver) - assert operation.operation_id == 'mock-1' - - response, status_code = resolver.mock_operation(operation) - assert status_code == 200 - assert response == {'foo': 'bar'} - - -def test_mock_resolver_example_nested_in_list_openapi(): - resolver = MockResolver(mock_all=True) - - responses = { - 'default': { - 'content': { - 'application/json': { - 'schema': { - 'type': 'array', - 'items': { - 'type': 'string', - 'example': 'bar' - } - } - } - } - } - } - - operation = OpenAPIOperation(api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'responses': responses - }, - app_security=[], - resolver=resolver) - assert operation.operation_id == 'mock-1' - - response, status_code = resolver.mock_operation(operation) - assert status_code == 200 - assert response == ['bar'] - - -def test_mock_resolver_no_example_nested_in_object(): - resolver = MockResolver(mock_all=True) - - responses = { - '200': { - 'schema': { - 'type': 'object', - 'properties': { - 'foo': { - 'type': 'string', - } - }, - } - } - } - - operation = Swagger2Operation(api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'responses': responses - }, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions={}, - resolver=resolver) - assert operation.operation_id == 'mock-1' - - response, status_code = resolver.mock_operation(operation) - assert status_code == 200 - assert response == 'No example response was defined.' - - -def test_mock_resolver_no_example_nested_in_list_openapi(): - resolver = MockResolver(mock_all=True) - - responses = { - '202': { - 'content': { - 'application/json': { - 'schema': { - 'type': 'array', - 'items': { - 'type': 'string', - } - } - } - } - } - } - - operation = OpenAPIOperation(api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'responses': responses - }, - app_security=[], - resolver=resolver) - assert operation.operation_id == 'mock-1' - - response, status_code = resolver.mock_operation(operation) - assert status_code == 202 - assert response == 'No example response was defined.' - - -def test_mock_resolver_no_examples(): - resolver = MockResolver(mock_all=True) - - responses = { - '418': {} - } - - operation = Swagger2Operation(api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'responses': responses - }, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions={}, - resolver=resolver) - assert operation.operation_id == 'mock-1' - - response, status_code = resolver.mock_operation(operation) - assert status_code == 418 - assert response == 'No example response was defined.' - - -def test_mock_resolver_notimplemented(): - resolver = MockResolver(mock_all=False) - - responses = { - '418': {} - } - - # do not mock the existent functions - operation = Swagger2Operation(api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'operationId': 'fakeapi.hello.get' - }, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions={}, - resolver=resolver) - assert operation.operation_id == 'fakeapi.hello.get' - - # mock only the nonexistent ones - operation = Swagger2Operation(api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'operationId': 'fakeapi.hello.nonexistent_function', - 'responses': responses - }, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions={}, - resolver=resolver) - - # check if it is using the mock function - assert operation._resolution.function() == ( - 'No example response was defined.', 418) +from firetail.mock import MockResolver +from firetail.operations import OpenAPIOperation, Swagger2Operation + + +def test_mock_resolver_default(): + resolver = MockResolver(mock_all=True) + + responses = { + 'default': { + 'examples': { + 'application/json': { + 'foo': 'bar' + } + } + } + } + + operation = Swagger2Operation(api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'responses': responses + }, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions={}, + resolver=resolver) + assert operation.operation_id == 'mock-1' + + response, status_code = resolver.mock_operation(operation) + assert status_code == 200 + assert response == {'foo': 'bar'} + +def test_mock_resolver_numeric(): + resolver = MockResolver(mock_all=True) + + responses = { + '200': { + 'examples': { + 'application/json': { + 'foo': 'bar' + } + } + } + } + + operation = Swagger2Operation(api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'responses': responses + }, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions={}, + resolver=resolver) + assert operation.operation_id == 'mock-1' + + response, status_code = resolver.mock_operation(operation) + assert status_code == 200 + assert response == {'foo': 'bar'} + +def test_mock_resolver_example(): + resolver = MockResolver(mock_all=True) + + responses = { + 'default': { + 'schema': { + 'type': 'object', + 'properties': { + 'foo': { + 'type': 'string' + } + }, + 'example': { + 'foo': 'bar' + } + } + } + } + + operation = Swagger2Operation(api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'responses': responses + }, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions={}, + resolver=resolver) + assert operation.operation_id == 'mock-1' + + response, status_code = resolver.mock_operation(operation) + assert status_code == 200 + assert response == {'foo': 'bar'} + +def test_mock_resolver_example_nested_in_object(): + resolver = MockResolver(mock_all=True) + + responses = { + 'default': { + 'schema': { + 'type': 'object', + 'properties': { + 'foo': { + 'type': 'string', + 'example': 'bar' + } + }, + } + } + } + + operation = Swagger2Operation(api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'responses': responses + }, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions={}, + resolver=resolver) + assert operation.operation_id == 'mock-1' + + response, status_code = resolver.mock_operation(operation) + assert status_code == 200 + assert response == {'foo': 'bar'} + +def test_mock_resolver_example_nested_in_list(): + resolver = MockResolver(mock_all=True) + + responses = { + 'default': { + 'schema': { + 'type': 'array', + 'items': { + 'type': 'string', + 'example': 'bar' + }, + } + } + } + + operation = Swagger2Operation(api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'responses': responses + }, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions={}, + resolver=resolver) + assert operation.operation_id == 'mock-1' + + response, status_code = resolver.mock_operation(operation) + assert status_code == 200 + assert response == ['bar'] + +def test_mock_resolver_example_nested_in_object_openapi(): + resolver = MockResolver(mock_all=True) + + responses = { + 'default': { + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'properties': { + 'foo': { + 'type': 'string', + 'example': 'bar' + } + } + } + } + } + } + } + + operation = OpenAPIOperation(api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'responses': responses + }, + app_security=[], + resolver=resolver) + assert operation.operation_id == 'mock-1' + + response, status_code = resolver.mock_operation(operation) + assert status_code == 200 + assert response == {'foo': 'bar'} + +def test_mock_resolver_example_nested_in_list_openapi(): + resolver = MockResolver(mock_all=True) + + responses = { + 'default': { + 'content': { + 'application/json': { + 'schema': { + 'type': 'array', + 'items': { + 'type': 'string', + 'example': 'bar' + } + } + } + } + } + } + + operation = OpenAPIOperation(api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'responses': responses + }, + app_security=[], + resolver=resolver) + assert operation.operation_id == 'mock-1' + + response, status_code = resolver.mock_operation(operation) + assert status_code == 200 + assert response == ['bar'] + +def test_mock_resolver_no_example_nested_in_object(): + resolver = MockResolver(mock_all=True) + + responses = { + '200': { + 'schema': { + 'type': 'object', + 'properties': { + 'foo': { + 'type': 'string', + } + }, + } + } + } + + operation = Swagger2Operation(api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'responses': responses + }, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions={}, + resolver=resolver) + assert operation.operation_id == 'mock-1' + + response, status_code = resolver.mock_operation(operation) + assert status_code == 200 + assert response == 'No example response was defined.' + +def test_mock_resolver_no_example_nested_in_list_openapi(): + resolver = MockResolver(mock_all=True) + + responses = { + '202': { + 'content': { + 'application/json': { + 'schema': { + 'type': 'array', + 'items': { + 'type': 'string', + } + } + } + } + } + } + + operation = OpenAPIOperation(api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'responses': responses + }, + app_security=[], + resolver=resolver) + assert operation.operation_id == 'mock-1' + + response, status_code = resolver.mock_operation(operation) + assert status_code == 202 + assert response == 'No example response was defined.' + +def test_mock_resolver_no_examples(): + resolver = MockResolver(mock_all=True) + + responses = { + '418': {} + } + + operation = Swagger2Operation(api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'responses': responses + }, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions={}, + resolver=resolver) + assert operation.operation_id == 'mock-1' + + response, status_code = resolver.mock_operation(operation) + assert status_code == 418 + assert response == 'No example response was defined.' + + +def test_mock_resolver_notimplemented(): + resolver = MockResolver(mock_all=False) + + responses = { + '418': {} + } + + # do not mock the existent functions + operation = Swagger2Operation(api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'operationId': 'fakeapi.hello.get' + }, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions={}, + resolver=resolver) + assert operation.operation_id == 'fakeapi.hello.get' + + # mock only the nonexistent ones + operation = Swagger2Operation(api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'operationId': 'fakeapi.hello.nonexistent_function', + 'responses': responses + }, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions={}, + resolver=resolver) + + # check if it is using the mock function + assert operation._resolution.function() == ('No example response was defined.', 418) diff --git a/tests/test_mock3.py b/tests/test_mock3.py index ba0f976..72b9642 100644 --- a/tests/test_mock3.py +++ b/tests/test_mock3.py @@ -1,183 +1,180 @@ -from firetail.mock import MockResolver -from firetail.operations import OpenAPIOperation - - -def test_mock_resolver_default(): - resolver = MockResolver(mock_all=True) - - responses = { - 'default': { - 'content': { - 'application/json': { - 'examples': { - "super_cool_example": { - "value": { - 'foo': 'bar' - } - } - } - } - } - } - } - - operation = OpenAPIOperation( - api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'responses': responses - }, - app_security=[], - resolver=resolver - ) - assert operation.operation_id == 'mock-1' - - response, status_code = resolver.mock_operation(operation) - assert status_code == 200 - assert response == {'foo': 'bar'} - - -def test_mock_resolver_numeric(): - resolver = MockResolver(mock_all=True) - - responses = { - '200': { - 'content': { - 'application/json': { - 'examples': { - "super_cool_example": { - "value": { - 'foo': 'bar' - } - } - } - } - } - } - } - - operation = OpenAPIOperation( - api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'responses': responses - }, - app_security=[], - resolver=resolver - ) - assert operation.operation_id == 'mock-1' - - response, status_code = resolver.mock_operation(operation) - assert status_code == 200 - assert response == {'foo': 'bar'} - - -def test_mock_resolver_inline_schema_example(): - resolver = MockResolver(mock_all=True) - - responses = { - 'default': { - 'content': { - 'application/json': { - 'schema': { - 'type': 'object', - 'properties': { - 'foo': { - 'schema': { - 'type': 'string' - } - } - } - }, - 'example': { - 'foo': 'bar' - } - } - } - } - } - - operation = OpenAPIOperation( - api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'responses': responses - }, - app_security=[], - resolver=resolver - ) - assert operation.operation_id == 'mock-1' - - response, status_code = resolver.mock_operation(operation) - assert status_code == 200 - assert response == {'foo': 'bar'} - - -def test_mock_resolver_no_examples(): - resolver = MockResolver(mock_all=True) - - responses = { - '418': {} - } - - operation = OpenAPIOperation( - api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'responses': responses - }, - app_security=[], - resolver=resolver - ) - assert operation.operation_id == 'mock-1' - - response, status_code = resolver.mock_operation(operation) - assert status_code == 418 - assert response == 'No example response was defined.' - - -def test_mock_resolver_notimplemented(): - resolver = MockResolver(mock_all=False) - - responses = { - '418': {} - } - - # do not mock the existent functions - operation = OpenAPIOperation( - api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'operationId': 'fakeapi.hello.get' - }, - app_security=[], - resolver=resolver - ) - assert operation.operation_id == 'fakeapi.hello.get' - - # mock only the nonexistent ones - operation = OpenAPIOperation( - api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'operationId': 'fakeapi.hello.nonexistent_function', - 'responses': responses - }, - app_security=[], - resolver=resolver - ) - # check if it is using the mock function - assert operation._resolution.function() == ( - 'No example response was defined.', 418) +from firetail.mock import MockResolver +from firetail.operations import OpenAPIOperation + + +def test_mock_resolver_default(): + resolver = MockResolver(mock_all=True) + + responses = { + 'default': { + 'content': { + 'application/json': { + 'examples': { + "super_cool_example": { + "value": { + 'foo': 'bar' + } + } + } + } + } + } + } + + operation = OpenAPIOperation( + api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'responses': responses + }, + app_security=[], + resolver=resolver + ) + assert operation.operation_id == 'mock-1' + + response, status_code = resolver.mock_operation(operation) + assert status_code == 200 + assert response == {'foo': 'bar'} + + +def test_mock_resolver_numeric(): + resolver = MockResolver(mock_all=True) + + responses = { + '200': { + 'content': { + 'application/json': { + 'examples': { + "super_cool_example": { + "value": { + 'foo': 'bar' + } + } + } + } + } + } + } + + operation = OpenAPIOperation( + api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'responses': responses + }, + app_security=[], + resolver=resolver + ) + assert operation.operation_id == 'mock-1' + + response, status_code = resolver.mock_operation(operation) + assert status_code == 200 + assert response == {'foo': 'bar'} + + +def test_mock_resolver_inline_schema_example(): + resolver = MockResolver(mock_all=True) + + responses = { + 'default': { + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'properties': { + 'foo': { + 'schema': { + 'type': 'string' + } + } + } + }, + 'example': { + 'foo': 'bar' + } + } + } + } + } + + operation = OpenAPIOperation( + api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'responses': responses + }, + app_security=[], + resolver=resolver + ) + assert operation.operation_id == 'mock-1' + + response, status_code = resolver.mock_operation(operation) + assert status_code == 200 + assert response == {'foo': 'bar'} + +def test_mock_resolver_no_examples(): + resolver = MockResolver(mock_all=True) + + responses = { + '418': {} + } + + operation = OpenAPIOperation( + api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'responses': responses + }, + app_security=[], + resolver=resolver + ) + assert operation.operation_id == 'mock-1' + + response, status_code = resolver.mock_operation(operation) + assert status_code == 418 + assert response == 'No example response was defined.' + +def test_mock_resolver_notimplemented(): + resolver = MockResolver(mock_all=False) + + responses = { + '418': {} + } + + # do not mock the existent functions + operation = OpenAPIOperation( + api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'operationId': 'fakeapi.hello.get' + }, + app_security=[], + resolver=resolver + ) + assert operation.operation_id == 'fakeapi.hello.get' + + # mock only the nonexistent ones + operation = OpenAPIOperation( + api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'operationId': 'fakeapi.hello.nonexistent_function', + 'responses': responses + }, + app_security=[], + resolver=resolver + ) + # check if it is using the mock function + assert operation._resolution.function() == ('No example response was defined.', 418) diff --git a/tests/test_operation2.py b/tests/test_operation2.py index 743a0f6..fc89a03 100644 --- a/tests/test_operation2.py +++ b/tests/test_operation2.py @@ -1,666 +1,654 @@ -import copy -import logging -import math -import pathlib -import types -from unittest import mock - -import pytest -from firetail.apis.flask_api import Jsonifier -from firetail.exceptions import InvalidSpecification -from firetail.json_schema import resolve_refs -from firetail.operations import Swagger2Operation -from firetail.resolver import Resolver - -TEST_FOLDER = pathlib.Path(__file__).parent - -DEFINITIONS = {'new_stack': {'required': ['image_version', 'keep_stacks', 'new_traffic', 'senza_yaml'], - 'type': 'object', - 'properties': {'keep_stacks': {'type': 'integer', - 'description': - 'Number of older stacks to keep'}, - 'image_version': {'type': 'string', - 'description': - 'Docker image version to deploy'}, - 'senza_yaml': {'type': 'string', - 'description': 'YAML to provide to senza'}, - 'new_traffic': {'type': 'integer', - 'description': - 'Percentage of the traffic'}}}, - 'composed': {'required': ['test'], - 'type': 'object', - 'properties': {'test': {'schema': {'$ref': '#/definitions/new_stack'}}}}, - 'problem': {"not": "defined"}} -PARAMETER_DEFINITIONS = {'myparam': {'in': 'path', 'type': 'integer'}} - -OPERATION1 = {'description': 'Adds a new stack to be created by lizzy and returns the ' - 'information needed to keep track of deployment', - 'operationId': 'fakeapi.hello.post_greeting', - 'parameters': [{'in': 'body', - 'name': 'new_stack', - 'required': True, - 'schema': {'$ref': '#/definitions/new_stack'}}], - 'responses': {201: {'description': 'Stack to be created. The ' - 'CloudFormation Stack creation can ' - "still fail if it's rejected by senza " - 'or AWS CF.', - 'schema': {'$ref': '#/definitions/new_stack'}}, - 400: {'description': 'Stack was not created because request ' - 'was invalid', - 'schema': {'$ref': '#/definitions/problem'}}, - 401: {'description': 'Stack was not created because the ' - 'access token was not provided or was ' - 'not valid for this operation', - 'schema': {'$ref': '#/definitions/problem'}}}, - 'security': [{'oauth': ['uid']}], - 'summary': 'Create new stack'} - -OPERATION2 = {'description': 'Adds a new stack to be created by lizzy and returns the ' - 'information needed to keep track of deployment', - 'operationId': 'fakeapi.hello.post_greeting', - 'parameters': [{'in': 'body', - 'name': 'new_stack', - 'required': True, - 'schema': {'$ref': '#/definitions/new_stack'}}, - {'in': 'body', - 'name': 'new_stack', - 'required': True, - 'schema': {'$ref': '#/definitions/new_stack'}}], - 'responses': {201: {'description': 'Stack to be created. The ' - 'CloudFormation Stack creation can ' - "still fail if it's rejected by senza " - 'or AWS CF.', - 'schema': {'$ref': '#/definitions/new_stack'}}, - 400: {'description': 'Stack was not created because request ' - 'was invalid', - 'schema': {'$ref': '#/definitions/problem'}}, - 401: {'description': 'Stack was not created because the ' - 'access token was not provided or was ' - 'not valid for this operation', - 'schema': {'$ref': '#/definitions/problem'}}}, - 'security': [{'oauth': ['uid']}], - 'summary': 'Create new stack'} - -OPERATION3 = {'operationId': 'fakeapi.hello.post_greeting', - 'parameters': [{'$ref': '#/parameters/myparam'}]} - -OPERATION4 = {'description': 'Adds a new stack to be created by lizzy and returns the ' - 'information needed to keep track of deployment', - 'operationId': 'fakeapi.hello.post_greeting', - 'parameters': [ - { - 'in': 'body', - 'name': 'new_stack', - 'required': True, - 'schema': {'$ref': '#/definitions/new_stack'} - }, - { - 'in': 'query', - 'name': 'stack_version', - 'default': 'one', - 'type': 'number' - } - ], - 'responses': {201: {'description': 'Stack to be created. The ' - 'CloudFormation Stack creation can ' - "still fail if it's rejected by senza " - 'or AWS CF.', - 'schema': {'$ref': '#/definitions/new_stack'}}, - 400: {'description': 'Stack was not created because request ' - 'was invalid', - 'schema': {'$ref': '#/definitions/problem'}}, - 401: {'description': 'Stack was not created because the ' - 'access token was not provided or was ' - 'not valid for this operation', - 'schema': {'$ref': '#/definitions/problem'}}}, - 'summary': 'Create new stack'} - -OPERATION5 = { - 'description': 'Adds a new stack to be created by lizzy and returns the ' - 'information needed to keep track of deployment', - 'operationId': 'fakeapi.hello.post_greeting', - 'parameters': [ - { - 'in': 'body', - 'name': 'new_stack', - 'required': True, - 'type': 'integer', - 'default': 'stack' - } - ], - 'responses': {'201': {'description': 'Stack to be created. The ' - 'CloudFormation Stack creation can ' - "still fail if it's rejected by senza " - 'or AWS CF.', - 'schema': {'$ref': '#/definitions/new_stack'}}, - '400': {'description': 'Stack was not created because request ' - 'was invalid', - 'schema': {'$ref': '#/definitions/problem'}}, - '401': {'description': 'Stack was not created because the ' - 'access token was not provided or was ' - 'not valid for this operation', - 'schema': {'$ref': '#/definitions/problem'}}}, - 'security': [{'oauth': ['uid']}], - 'summary': 'Create new stack' -} - -OPERATION6 = { - 'operationId': 'fakeapi.hello.schema', - 'parameters': [ - { - 'type': 'object', - 'in': 'body', - 'name': 'new_stack', - 'default': {'keep_stack': 1, 'image_version': 1, 'senza_yaml': 'senza.yaml', - 'new_traffic': 100}, - 'schema': {'$ref': '#/definitions/new_stack'} - } - ], - 'responses': {}, - 'security': [{'oauth': ['uid']}], - 'summary': 'Create new stack' -} - -OPERATION7 = {'description': 'Adds a new stack to be created by lizzy and returns the ' - 'information needed to keep track of deployment', - 'operationId': 'fakeapi.hello.post_greeting', - 'parameters': [{'in': 'body', - 'name': 'new_stack', - 'required': True, - 'schema': {'type': 'array', 'items': {'$ref': '#/definitions/new_stack'}}}], - 'responses': {'201': {'description': 'Stack to be created. The ' - 'CloudFormation Stack creation can ' - "still fail if it's rejected by senza " - 'or AWS CF.', - 'schema': {'$ref': '#/definitions/new_stack'}}, - '400': {'description': 'Stack was not created because request ' - 'was invalid', - 'schema': {'$ref': '#/definitions/problem'}}, - '401': {'description': 'Stack was not created because the ' - 'access token was not provided or was ' - 'not valid for this operation', - 'schema': {'$ref': '#/definitions/problem'}}}, - 'security': [{'oauth': ['uid']}], - 'summary': 'Create new stack'} - -OPERATION8 = {'description': 'Adds a new stack to be created by lizzy and returns the ' - 'information needed to keep track of deployment', - 'operationId': 'fakeapi.hello.post_greeting', - 'parameters': [{'in': 'body', - 'name': 'test', - 'required': True, - 'schema': {'$ref': '#/definitions/composed'}}], - 'responses': {'201': {'description': 'Stack to be created. The ' - 'CloudFormation Stack creation can ' - "still fail if it's rejected by senza " - 'or AWS CF.', - 'schema': {'$ref': '#/definitions/new_stack'}}, - '400': {'description': 'Stack was not created because request ' - 'was invalid', - 'schema': {'$ref': '#/definitions/problem'}}, - '401': {'description': 'Stack was not created because the ' - 'access token was not provided or was ' - 'not valid for this operation', - 'schema': {'$ref': '#/definitions/problem'}}}, - 'security': [{'oauth': ['uid']}], - 'summary': 'Create new stack'} - -OPERATION9 = {'description': 'operation secured with 2 api keys', - 'operationId': 'fakeapi.hello.post_greeting', - 'responses': {'200': {'description': 'OK'}}, - 'security': [{'key1': [], 'key2': []}]} - -OPERATION10 = {'description': 'operation secured with 2 oauth schemes combined using logical AND', - 'operationId': 'fakeapi.hello.post_greeting', - 'responses': {'200': {'description': 'OK'}}, - 'security': [{'oauth_1': ['uid'], 'oauth_2': ['uid']}]} - -OPERATION11 = {'description': 'operation secured with an oauth schemes with 2 possible scopes (in OR)', - 'operationId': 'fakeapi.hello.post_greeting', - 'responses': {'200': {'description': 'OK'}}, - 'security': [{'oauth': ['myscope']}, {'oauth': ['myscope2']}]} - -SECURITY_DEFINITIONS_REMOTE = {'oauth': {'type': 'oauth2', - 'flow': 'password', - 'x-tokenInfoUrl': 'https://oauth.example/token_info', - 'scopes': {'myscope': 'can do stuff'}}} - -SECURITY_DEFINITIONS_LOCAL = {'oauth': {'type': 'oauth2', - 'flow': 'password', - 'x-tokenInfoFunc': 'math.ceil', - 'scopes': {'myscope': 'can do stuff', - 'myscope2': 'can do other stuff'}}} - -SECURITY_DEFINITIONS_BOTH = {'oauth': {'type': 'oauth2', - 'flow': 'password', - 'x-tokenInfoFunc': 'math.ceil', - 'x-tokenInfoUrl': 'https://oauth.example/token_info', - 'scopes': {'myscope': 'can do stuff'}}} - -SECURITY_DEFINITIONS_WO_INFO = {'oauth': {'type': 'oauth2', - 'flow': 'password', - 'scopes': {'myscope': 'can do stuff'}}} - -SECURITY_DEFINITIONS_2_KEYS = {'key1': {'type': 'apiKey', - 'in': 'header', - 'name': 'X-Auth-1', - 'x-apikeyInfoFunc': 'math.ceil'}, - 'key2': {'type': 'apiKey', - 'in': 'header', - 'name': 'X-Auth-2', - 'x-apikeyInfoFunc': 'math.ceil'}} - -SECURITY_DEFINITIONS_2_OAUTH = {'oauth_1': {'type': 'oauth2', - 'flow': 'password', - 'x-tokenInfoFunc': 'math.ceil', - 'scopes': {'myscope': 'can do stuff'}}, - 'oauth_2': {'type': 'oauth2', - 'flow': 'password', - 'x-tokenInfoFunc': 'math.ceil', - 'scopes': {'myscope': 'can do stuff'}}} - - -@pytest.fixture -def api(security_handler_factory): - api = mock.MagicMock(jsonifier=Jsonifier) - api.security_handler_factory = security_handler_factory - yield api - - -def make_operation(op, definitions=True, parameters=True): - """ note the wrapper because definitions namespace and - operation namespace collide - """ - new_op = {"wrapper": copy.deepcopy(op)} - if definitions: - new_op.update({"definitions": DEFINITIONS}) - if parameters: - new_op.update({"parameters": PARAMETER_DEFINITIONS}) - return resolve_refs(new_op)["wrapper"] - - -def test_operation(api, security_handler_factory): - verify_oauth = mock.MagicMock(return_value='verify_oauth_result') - security_handler_factory.verify_oauth = verify_oauth - security_handler_factory.get_token_info_remote = mock.MagicMock( - return_value='get_token_info_remote_result') - - op_spec = make_operation(OPERATION1) - operation = Swagger2Operation(api=api, - method='GET', - path='endpoint', - path_parameters=[], - operation=op_spec, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions=SECURITY_DEFINITIONS_REMOTE, - definitions=DEFINITIONS, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=Resolver()) - assert isinstance(operation.function, types.FunctionType) - - security_decorator = operation.security_decorator - assert len(security_decorator.args[0]) == 1 - assert security_decorator.args[0][0] == 'verify_oauth_result' - verify_oauth.assert_called_with( - 'get_token_info_remote_result', security_handler_factory.validate_scope, ['uid']) - security_handler_factory.get_token_info_remote.assert_called_with( - 'https://oauth.example/token_info') - - assert operation.method == 'GET' - assert operation.produces == ['application/json'] - assert operation.consumes == ['application/json'] - assert operation.security == [{'oauth': ['uid']}] - - expected_body_schema = op_spec["parameters"][0]["schema"] - expected_body_schema.update({'definitions': DEFINITIONS}) - assert operation.body_schema == expected_body_schema - - -def test_operation_array(api): - op_spec = make_operation(OPERATION7) - operation = Swagger2Operation(api=api, - method='GET', - path='endpoint', - path_parameters=[], - operation=op_spec, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions=SECURITY_DEFINITIONS_REMOTE, - definitions=DEFINITIONS, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=Resolver()) - assert isinstance(operation.function, types.FunctionType) - - assert operation.method == 'GET' - assert operation.produces == ['application/json'] - assert operation.consumes == ['application/json'] - assert operation.security == [{'oauth': ['uid']}] - expected_body_schema = { - 'type': 'array', - 'items': DEFINITIONS["new_stack"], - 'definitions': DEFINITIONS - } - assert operation.body_schema == expected_body_schema - - -def test_operation_composed_definition(api): - op_spec = make_operation(OPERATION8) - operation = Swagger2Operation(api=api, - method='GET', - path='endpoint', - path_parameters=[], - operation=op_spec, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions=SECURITY_DEFINITIONS_REMOTE, - definitions=DEFINITIONS, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=Resolver()) - assert isinstance(operation.function, types.FunctionType) - - assert operation.method == 'GET' - assert operation.produces == ['application/json'] - assert operation.consumes == ['application/json'] - assert operation.security == [{'oauth': ['uid']}] - expected_body_schema = op_spec["parameters"][0]["schema"] - expected_body_schema.update({'definitions': DEFINITIONS}) - assert operation.body_schema == expected_body_schema - - -def test_operation_local_security_oauth2(api): - verify_oauth = mock.MagicMock(return_value='verify_oauth_result') - api.security_handler_factory.verify_oauth = verify_oauth - - op_spec = make_operation(OPERATION8) - operation = Swagger2Operation(api=api, - method='GET', - path='endpoint', - path_parameters=[], - operation=op_spec, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions=SECURITY_DEFINITIONS_LOCAL, - definitions=DEFINITIONS, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=Resolver()) - assert isinstance(operation.function, types.FunctionType) - security_decorator = operation.security_decorator - assert len(security_decorator.args[0]) == 1 - assert security_decorator.args[0][0] == 'verify_oauth_result' - verify_oauth.assert_called_with( - math.ceil, api.security_handler_factory.validate_scope, ['uid']) - - assert operation.method == 'GET' - assert operation.produces == ['application/json'] - assert operation.consumes == ['application/json'] - assert operation.security == [{'oauth': ['uid']}] - expected_body_schema = op_spec["parameters"][0]["schema"] - expected_body_schema.update({'definitions': DEFINITIONS}) - assert operation.body_schema == expected_body_schema - - -def test_operation_local_security_duplicate_token_info(api): - verify_oauth = mock.MagicMock(return_value='verify_oauth_result') - api.security_handler_factory.verify_oauth = verify_oauth - - op_spec = make_operation(OPERATION8) - operation = Swagger2Operation(api=api, - method='GET', - path='endpoint', - path_parameters=[], - operation=op_spec, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions=SECURITY_DEFINITIONS_BOTH, - definitions=DEFINITIONS, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=Resolver()) - assert isinstance(operation.function, types.FunctionType) - - security_decorator = operation.security_decorator - assert len(security_decorator.args[0]) == 1 - assert security_decorator.args[0][0] == 'verify_oauth_result' - verify_oauth.call_args.assert_called_with( - math.ceil, api.security_handler_factory.validate_scope, ['uid']) - - assert operation.method == 'GET' - assert operation.produces == ['application/json'] - assert operation.consumes == ['application/json'] - assert operation.security == [{'oauth': ['uid']}] - expected_body_schema = op_spec["parameters"][0]["schema"] - expected_body_schema.update({'definitions': DEFINITIONS}) - assert operation.body_schema == expected_body_schema - - -def test_multi_body(api): - with pytest.raises(InvalidSpecification) as exc_info: # type: py.code.ExceptionInfo - op_spec = make_operation(OPERATION2) - operation = Swagger2Operation(api=api, - method='GET', - path='endpoint', - path_parameters=[], - operation=op_spec, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions=DEFINITIONS, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=Resolver()) - operation.body_schema - - exception = exc_info.value - assert str( - exception) == "GET endpoint There can be one 'body' parameter at most" - assert repr( - exception) == """""" - - -def test_no_token_info(api): - op_spec = make_operation(OPERATION1) - operation = Swagger2Operation(api=api, - method='GET', - path='endpoint', - path_parameters=[], - operation=op_spec, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=SECURITY_DEFINITIONS_WO_INFO, - security_definitions=SECURITY_DEFINITIONS_WO_INFO, - definitions=DEFINITIONS, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=Resolver()) - assert isinstance(operation.function, types.FunctionType) - - security_decorator = operation.security_decorator - assert len(security_decorator.args[0]) == 0 - - assert operation.method == 'GET' - assert operation.produces == ['application/json'] - assert operation.consumes == ['application/json'] - assert operation.security == [{'oauth': ['uid']}] - - expected_body_schema = {'definitions': DEFINITIONS} - expected_body_schema.update(DEFINITIONS["new_stack"]) - assert operation.body_schema == expected_body_schema - - -def test_multiple_security_schemes_and(api): - """Tests an operation with multiple security schemes in AND fashion.""" - def return_api_key_name(func, in_, name): - return name - verify_api_key = mock.MagicMock(side_effect=return_api_key_name) - api.security_handler_factory.verify_api_key = verify_api_key - verify_multiple = mock.MagicMock(return_value='verify_multiple_result') - api.security_handler_factory.verify_multiple_schemes = verify_multiple - - op_spec = make_operation(OPERATION9) - operation = Swagger2Operation(api=api, - method='GET', - path='endpoint', - path_parameters=[], - operation=op_spec, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=SECURITY_DEFINITIONS_2_KEYS, - security_definitions=SECURITY_DEFINITIONS_2_KEYS, - definitions=DEFINITIONS, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=Resolver()) - assert isinstance(operation.function, types.FunctionType) - assert verify_api_key.call_count == 2 - verify_api_key.assert_any_call(math.ceil, 'header', 'X-Auth-1') - verify_api_key.assert_any_call(math.ceil, 'header', 'X-Auth-2') - # Assert verify_multiple_schemes is called with mapping from scheme name - # to result of security_handler_factory.verify_api_key() - verify_multiple.assert_called_with( - {'key1': 'X-Auth-1', 'key2': 'X-Auth-2'}) - - security_decorator = operation.security_decorator - assert len(security_decorator.args[0]) == 1 - assert security_decorator.args[0][0] == 'verify_multiple_result' - - assert operation.method == 'GET' - assert operation.produces == ['application/json'] - assert operation.consumes == ['application/json'] - assert operation.security == [{'key1': [], 'key2': []}] - - -def test_multiple_oauth_in_and(api, caplog): - """Tests an operation with multiple oauth security schemes in AND fashion. - These should be ignored and raise a warning. - """ - caplog.set_level(logging.WARNING, logger="firetail.operations.secure") - verify_oauth = mock.MagicMock(return_value='verify_oauth_result') - api.security_handler_factory.verify_oauth = verify_oauth - - op_spec = make_operation(OPERATION10) - operation = Swagger2Operation(api=api, - method='GET', - path='endpoint', - path_parameters=[], - operation=op_spec, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=SECURITY_DEFINITIONS_2_OAUTH, - security_definitions=SECURITY_DEFINITIONS_2_OAUTH, - definitions=DEFINITIONS, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=Resolver()) - assert isinstance(operation.function, types.FunctionType) - - security_decorator = operation.security_decorator - assert len(security_decorator.args[0]) == 0 - assert security_decorator.args[0] == [] - - assert '... multiple OAuth2 security schemes in AND fashion not supported' in caplog.text - - assert operation.method == 'GET' - assert operation.produces == ['application/json'] - assert operation.consumes == ['application/json'] - assert operation.security == [{'oauth_1': ['uid'], 'oauth_2': ['uid']}] - - -def test_parameter_reference(api): - op_spec = make_operation(OPERATION3, definitions=False) - operation = Swagger2Operation(api=api, - method='GET', - path='endpoint', - path_parameters=[], - operation=op_spec, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=Resolver()) - assert operation.parameters == [{'in': 'path', 'type': 'integer'}] - - -def test_default(api): - op_spec = make_operation(OPERATION4) - op_spec['parameters'][1]['default'] = 1 - Swagger2Operation( - api=api, method='GET', path='endpoint', path_parameters=[], - operation=op_spec, app_produces=['application/json'], - app_consumes=['application/json'], app_security=[], - security_definitions={}, definitions=DEFINITIONS, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=Resolver() - ) - op_spec = make_operation(OPERATION6, parameters=False) - op_spec['parameters'][0]['default'] = { - 'keep_stacks': 1, - 'image_version': 'one', - 'senza_yaml': 'senza.yaml', - 'new_traffic': 100 - } - Swagger2Operation( - api=api, method='POST', path='endpoint', path_parameters=[], - operation=op_spec, app_produces=['application/json'], - app_consumes=['application/json'], app_security=[], - security_definitions={}, definitions=DEFINITIONS, - parameter_definitions={}, resolver=Resolver() - ) - - -def test_get_path_parameter_types(api): - op_spec = make_operation(OPERATION1, parameters=False) - op_spec['parameters'] = [ - {'in': 'path', 'type': 'int', 'name': 'int_path'}, - {'in': 'path', 'type': 'string', 'name': 'string_path'}, - {'in': 'path', 'type': 'string', 'format': 'path', 'name': 'path_path'} - ] - - operation = Swagger2Operation( - api=api, method='GET', path='endpoint', path_parameters=[], - operation=op_spec, app_produces=['application/json'], - app_consumes=['application/json'], - definitions=DEFINITIONS, resolver=Resolver() - ) - - assert {'int_path': 'int', 'string_path': 'string', - 'path_path': 'path'} == operation.get_path_parameter_types() - - -def test_oauth_scopes_in_or(api): - """Tests whether an OAuth security scheme with 2 different possible scopes is correctly handled.""" - verify_oauth = mock.MagicMock(return_value='verify_oauth_result') - api.security_handler_factory.verify_oauth = verify_oauth - - op_spec = make_operation(OPERATION11) - operation = Swagger2Operation(api=api, - method='GET', - path='endpoint', - path_parameters=[], - operation=op_spec, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions=SECURITY_DEFINITIONS_LOCAL, - definitions=DEFINITIONS, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=Resolver()) - assert isinstance(operation.function, types.FunctionType) - security_decorator = operation.security_decorator - assert len(security_decorator.args[0]) == 2 - assert security_decorator.args[0][0] == 'verify_oauth_result' - assert security_decorator.args[0][1] == 'verify_oauth_result' - verify_oauth.assert_has_calls([ - mock.call( - math.ceil, api.security_handler_factory.validate_scope, ['myscope']), - mock.call( - math.ceil, api.security_handler_factory.validate_scope, ['myscope2']), - ]) - - assert operation.method == 'GET' - assert operation.produces == ['application/json'] - assert operation.consumes == ['application/json'] - assert operation.security == [ - {'oauth': ['myscope']}, {'oauth': ['myscope2']}] +import copy +import logging +import math +import pathlib +import types +from unittest import mock + +import pytest +from firetail.apis.flask_api import Jsonifier +from firetail.exceptions import InvalidSpecification +from firetail.json_schema import resolve_refs +from firetail.operations import Swagger2Operation +from firetail.resolver import Resolver + +TEST_FOLDER = pathlib.Path(__file__).parent + +DEFINITIONS = {'new_stack': {'required': ['image_version', 'keep_stacks', 'new_traffic', 'senza_yaml'], + 'type': 'object', + 'properties': {'keep_stacks': {'type': 'integer', + 'description': + 'Number of older stacks to keep'}, + 'image_version': {'type': 'string', + 'description': + 'Docker image version to deploy'}, + 'senza_yaml': {'type': 'string', + 'description': 'YAML to provide to senza'}, + 'new_traffic': {'type': 'integer', + 'description': + 'Percentage of the traffic'}}}, + 'composed': {'required': ['test'], + 'type': 'object', + 'properties': {'test': {'schema': {'$ref': '#/definitions/new_stack'}}}}, + 'problem': {"not": "defined"}} +PARAMETER_DEFINITIONS = {'myparam': {'in': 'path', 'type': 'integer'}} + +OPERATION1 = {'description': 'Adds a new stack to be created by lizzy and returns the ' + 'information needed to keep track of deployment', + 'operationId': 'fakeapi.hello.post_greeting', + 'parameters': [{'in': 'body', + 'name': 'new_stack', + 'required': True, + 'schema': {'$ref': '#/definitions/new_stack'}}], + 'responses': {201: {'description': 'Stack to be created. The ' + 'CloudFormation Stack creation can ' + "still fail if it's rejected by senza " + 'or AWS CF.', + 'schema': {'$ref': '#/definitions/new_stack'}}, + 400: {'description': 'Stack was not created because request ' + 'was invalid', + 'schema': {'$ref': '#/definitions/problem'}}, + 401: {'description': 'Stack was not created because the ' + 'access token was not provided or was ' + 'not valid for this operation', + 'schema': {'$ref': '#/definitions/problem'}}}, + 'security': [{'oauth': ['uid']}], + 'summary': 'Create new stack'} + +OPERATION2 = {'description': 'Adds a new stack to be created by lizzy and returns the ' + 'information needed to keep track of deployment', + 'operationId': 'fakeapi.hello.post_greeting', + 'parameters': [{'in': 'body', + 'name': 'new_stack', + 'required': True, + 'schema': {'$ref': '#/definitions/new_stack'}}, + {'in': 'body', + 'name': 'new_stack', + 'required': True, + 'schema': {'$ref': '#/definitions/new_stack'}}], + 'responses': {201: {'description': 'Stack to be created. The ' + 'CloudFormation Stack creation can ' + "still fail if it's rejected by senza " + 'or AWS CF.', + 'schema': {'$ref': '#/definitions/new_stack'}}, + 400: {'description': 'Stack was not created because request ' + 'was invalid', + 'schema': {'$ref': '#/definitions/problem'}}, + 401: {'description': 'Stack was not created because the ' + 'access token was not provided or was ' + 'not valid for this operation', + 'schema': {'$ref': '#/definitions/problem'}}}, + 'security': [{'oauth': ['uid']}], + 'summary': 'Create new stack'} + +OPERATION3 = {'operationId': 'fakeapi.hello.post_greeting', + 'parameters': [{'$ref': '#/parameters/myparam'}]} + +OPERATION4 = {'description': 'Adds a new stack to be created by lizzy and returns the ' + 'information needed to keep track of deployment', + 'operationId': 'fakeapi.hello.post_greeting', + 'parameters': [ + { + 'in': 'body', + 'name': 'new_stack', + 'required': True, + 'schema': {'$ref': '#/definitions/new_stack'} + }, + { + 'in': 'query', + 'name': 'stack_version', + 'default': 'one', + 'type': 'number' + } + ], + 'responses': {201: {'description': 'Stack to be created. The ' + 'CloudFormation Stack creation can ' + "still fail if it's rejected by senza " + 'or AWS CF.', + 'schema': {'$ref': '#/definitions/new_stack'}}, + 400: {'description': 'Stack was not created because request ' + 'was invalid', + 'schema': {'$ref': '#/definitions/problem'}}, + 401: {'description': 'Stack was not created because the ' + 'access token was not provided or was ' + 'not valid for this operation', + 'schema': {'$ref': '#/definitions/problem'}}}, + 'summary': 'Create new stack'} + +OPERATION5 = { + 'description': 'Adds a new stack to be created by lizzy and returns the ' + 'information needed to keep track of deployment', + 'operationId': 'fakeapi.hello.post_greeting', + 'parameters': [ + { + 'in': 'body', + 'name': 'new_stack', + 'required': True, + 'type': 'integer', + 'default': 'stack' + } + ], + 'responses': {'201': {'description': 'Stack to be created. The ' + 'CloudFormation Stack creation can ' + "still fail if it's rejected by senza " + 'or AWS CF.', + 'schema': {'$ref': '#/definitions/new_stack'}}, + '400': {'description': 'Stack was not created because request ' + 'was invalid', + 'schema': {'$ref': '#/definitions/problem'}}, + '401': {'description': 'Stack was not created because the ' + 'access token was not provided or was ' + 'not valid for this operation', + 'schema': {'$ref': '#/definitions/problem'}}}, + 'security': [{'oauth': ['uid']}], + 'summary': 'Create new stack' +} + +OPERATION6 = { + 'operationId': 'fakeapi.hello.schema', + 'parameters': [ + { + 'type': 'object', + 'in': 'body', + 'name': 'new_stack', + 'default': {'keep_stack': 1, 'image_version': 1, 'senza_yaml': 'senza.yaml', + 'new_traffic': 100}, + 'schema': {'$ref': '#/definitions/new_stack'} + } + ], + 'responses': {}, + 'security': [{'oauth': ['uid']}], + 'summary': 'Create new stack' +} + +OPERATION7 = {'description': 'Adds a new stack to be created by lizzy and returns the ' + 'information needed to keep track of deployment', + 'operationId': 'fakeapi.hello.post_greeting', + 'parameters': [{'in': 'body', + 'name': 'new_stack', + 'required': True, + 'schema': {'type': 'array', 'items': {'$ref': '#/definitions/new_stack'}}}], + 'responses': {'201': {'description': 'Stack to be created. The ' + 'CloudFormation Stack creation can ' + "still fail if it's rejected by senza " + 'or AWS CF.', + 'schema': {'$ref': '#/definitions/new_stack'}}, + '400': {'description': 'Stack was not created because request ' + 'was invalid', + 'schema': {'$ref': '#/definitions/problem'}}, + '401': {'description': 'Stack was not created because the ' + 'access token was not provided or was ' + 'not valid for this operation', + 'schema': {'$ref': '#/definitions/problem'}}}, + 'security': [{'oauth': ['uid']}], + 'summary': 'Create new stack'} + +OPERATION8 = {'description': 'Adds a new stack to be created by lizzy and returns the ' + 'information needed to keep track of deployment', + 'operationId': 'fakeapi.hello.post_greeting', + 'parameters': [{'in': 'body', + 'name': 'test', + 'required': True, + 'schema': {'$ref': '#/definitions/composed'}}], + 'responses': {'201': {'description': 'Stack to be created. The ' + 'CloudFormation Stack creation can ' + "still fail if it's rejected by senza " + 'or AWS CF.', + 'schema': {'$ref': '#/definitions/new_stack'}}, + '400': {'description': 'Stack was not created because request ' + 'was invalid', + 'schema': {'$ref': '#/definitions/problem'}}, + '401': {'description': 'Stack was not created because the ' + 'access token was not provided or was ' + 'not valid for this operation', + 'schema': {'$ref': '#/definitions/problem'}}}, + 'security': [{'oauth': ['uid']}], + 'summary': 'Create new stack'} + +OPERATION9 = {'description': 'operation secured with 2 api keys', + 'operationId': 'fakeapi.hello.post_greeting', + 'responses': {'200': {'description': 'OK'}}, + 'security': [{'key1': [], 'key2': []}]} + +OPERATION10 = {'description': 'operation secured with 2 oauth schemes combined using logical AND', + 'operationId': 'fakeapi.hello.post_greeting', + 'responses': {'200': {'description': 'OK'}}, + 'security': [{'oauth_1': ['uid'], 'oauth_2': ['uid']}]} + +OPERATION11 = {'description': 'operation secured with an oauth schemes with 2 possible scopes (in OR)', + 'operationId': 'fakeapi.hello.post_greeting', + 'responses': {'200': {'description': 'OK'}}, + 'security': [{'oauth': ['myscope']}, {'oauth': ['myscope2']}]} + +SECURITY_DEFINITIONS_REMOTE = {'oauth': {'type': 'oauth2', + 'flow': 'password', + 'x-tokenInfoUrl': 'https://oauth.example/token_info', + 'scopes': {'myscope': 'can do stuff'}}} + +SECURITY_DEFINITIONS_LOCAL = {'oauth': {'type': 'oauth2', + 'flow': 'password', + 'x-tokenInfoFunc': 'math.ceil', + 'scopes': {'myscope': 'can do stuff', + 'myscope2': 'can do other stuff'}}} + +SECURITY_DEFINITIONS_BOTH = {'oauth': {'type': 'oauth2', + 'flow': 'password', + 'x-tokenInfoFunc': 'math.ceil', + 'x-tokenInfoUrl': 'https://oauth.example/token_info', + 'scopes': {'myscope': 'can do stuff'}}} + +SECURITY_DEFINITIONS_WO_INFO = {'oauth': {'type': 'oauth2', + 'flow': 'password', + 'scopes': {'myscope': 'can do stuff'}}} + +SECURITY_DEFINITIONS_2_KEYS = {'key1': {'type': 'apiKey', + 'in': 'header', + 'name': 'X-Auth-1', + 'x-apikeyInfoFunc': 'math.ceil'}, + 'key2': {'type': 'apiKey', + 'in': 'header', + 'name': 'X-Auth-2', + 'x-apikeyInfoFunc': 'math.ceil'}} + +SECURITY_DEFINITIONS_2_OAUTH = {'oauth_1': {'type': 'oauth2', + 'flow': 'password', + 'x-tokenInfoFunc': 'math.ceil', + 'scopes': {'myscope': 'can do stuff'}}, + 'oauth_2': {'type': 'oauth2', + 'flow': 'password', + 'x-tokenInfoFunc': 'math.ceil', + 'scopes': {'myscope': 'can do stuff'}}} + + +@pytest.fixture +def api(security_handler_factory): + api = mock.MagicMock(jsonifier=Jsonifier) + api.security_handler_factory = security_handler_factory + yield api + + +def make_operation(op, definitions=True, parameters=True): + """ note the wrapper because definitions namespace and + operation namespace collide + """ + new_op = {"wrapper": copy.deepcopy(op)} + if definitions: + new_op.update({"definitions": DEFINITIONS}) + if parameters: + new_op.update({"parameters": PARAMETER_DEFINITIONS}) + return resolve_refs(new_op)["wrapper"] + + +def test_operation(api, security_handler_factory): + verify_oauth = mock.MagicMock(return_value='verify_oauth_result') + security_handler_factory.verify_oauth = verify_oauth + security_handler_factory.get_token_info_remote = mock.MagicMock(return_value='get_token_info_remote_result') + + op_spec = make_operation(OPERATION1) + operation = Swagger2Operation(api=api, + method='GET', + path='endpoint', + path_parameters=[], + operation=op_spec, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions=SECURITY_DEFINITIONS_REMOTE, + definitions=DEFINITIONS, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=Resolver()) + assert isinstance(operation.function, types.FunctionType) + + security_decorator = operation.security_decorator + assert len(security_decorator.args[0]) == 1 + assert security_decorator.args[0][0] == 'verify_oauth_result' + verify_oauth.assert_called_with('get_token_info_remote_result', security_handler_factory.validate_scope, ['uid']) + security_handler_factory.get_token_info_remote.assert_called_with('https://oauth.example/token_info') + + assert operation.method == 'GET' + assert operation.produces == ['application/json'] + assert operation.consumes == ['application/json'] + assert operation.security == [{'oauth': ['uid']}] + + expected_body_schema = op_spec["parameters"][0]["schema"] + expected_body_schema.update({'definitions': DEFINITIONS}) + assert operation.body_schema == expected_body_schema + + +def test_operation_array(api): + op_spec = make_operation(OPERATION7) + operation = Swagger2Operation(api=api, + method='GET', + path='endpoint', + path_parameters=[], + operation=op_spec, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions=SECURITY_DEFINITIONS_REMOTE, + definitions=DEFINITIONS, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=Resolver()) + assert isinstance(operation.function, types.FunctionType) + + assert operation.method == 'GET' + assert operation.produces == ['application/json'] + assert operation.consumes == ['application/json'] + assert operation.security == [{'oauth': ['uid']}] + expected_body_schema = { + 'type': 'array', + 'items': DEFINITIONS["new_stack"], + 'definitions': DEFINITIONS + } + assert operation.body_schema == expected_body_schema + + +def test_operation_composed_definition(api): + op_spec = make_operation(OPERATION8) + operation = Swagger2Operation(api=api, + method='GET', + path='endpoint', + path_parameters=[], + operation=op_spec, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions=SECURITY_DEFINITIONS_REMOTE, + definitions=DEFINITIONS, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=Resolver()) + assert isinstance(operation.function, types.FunctionType) + + assert operation.method == 'GET' + assert operation.produces == ['application/json'] + assert operation.consumes == ['application/json'] + assert operation.security == [{'oauth': ['uid']}] + expected_body_schema = op_spec["parameters"][0]["schema"] + expected_body_schema.update({'definitions': DEFINITIONS}) + assert operation.body_schema == expected_body_schema + + +def test_operation_local_security_oauth2(api): + verify_oauth = mock.MagicMock(return_value='verify_oauth_result') + api.security_handler_factory.verify_oauth = verify_oauth + + op_spec = make_operation(OPERATION8) + operation = Swagger2Operation(api=api, + method='GET', + path='endpoint', + path_parameters=[], + operation=op_spec, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions=SECURITY_DEFINITIONS_LOCAL, + definitions=DEFINITIONS, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=Resolver()) + assert isinstance(operation.function, types.FunctionType) + security_decorator = operation.security_decorator + assert len(security_decorator.args[0]) == 1 + assert security_decorator.args[0][0] == 'verify_oauth_result' + verify_oauth.assert_called_with(math.ceil, api.security_handler_factory.validate_scope, ['uid']) + + assert operation.method == 'GET' + assert operation.produces == ['application/json'] + assert operation.consumes == ['application/json'] + assert operation.security == [{'oauth': ['uid']}] + expected_body_schema = op_spec["parameters"][0]["schema"] + expected_body_schema.update({'definitions': DEFINITIONS}) + assert operation.body_schema == expected_body_schema + + +def test_operation_local_security_duplicate_token_info(api): + verify_oauth = mock.MagicMock(return_value='verify_oauth_result') + api.security_handler_factory.verify_oauth = verify_oauth + + op_spec = make_operation(OPERATION8) + operation = Swagger2Operation(api=api, + method='GET', + path='endpoint', + path_parameters=[], + operation=op_spec, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions=SECURITY_DEFINITIONS_BOTH, + definitions=DEFINITIONS, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=Resolver()) + assert isinstance(operation.function, types.FunctionType) + + security_decorator = operation.security_decorator + assert len(security_decorator.args[0]) == 1 + assert security_decorator.args[0][0] == 'verify_oauth_result' + verify_oauth.call_args.assert_called_with(math.ceil, api.security_handler_factory.validate_scope, ['uid']) + + assert operation.method == 'GET' + assert operation.produces == ['application/json'] + assert operation.consumes == ['application/json'] + assert operation.security == [{'oauth': ['uid']}] + expected_body_schema = op_spec["parameters"][0]["schema"] + expected_body_schema.update({'definitions': DEFINITIONS}) + assert operation.body_schema == expected_body_schema + + +def test_multi_body(api): + with pytest.raises(InvalidSpecification) as exc_info: # type: py.code.ExceptionInfo + op_spec = make_operation(OPERATION2) + operation = Swagger2Operation(api=api, + method='GET', + path='endpoint', + path_parameters=[], + operation=op_spec, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions=DEFINITIONS, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=Resolver()) + operation.body_schema + + exception = exc_info.value + assert str(exception) == "GET endpoint There can be one 'body' parameter at most" + assert repr(exception) == """""" + + +def test_no_token_info(api): + op_spec = make_operation(OPERATION1) + operation = Swagger2Operation(api=api, + method='GET', + path='endpoint', + path_parameters=[], + operation=op_spec, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=SECURITY_DEFINITIONS_WO_INFO, + security_definitions=SECURITY_DEFINITIONS_WO_INFO, + definitions=DEFINITIONS, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=Resolver()) + assert isinstance(operation.function, types.FunctionType) + + security_decorator = operation.security_decorator + assert len(security_decorator.args[0]) == 0 + + assert operation.method == 'GET' + assert operation.produces == ['application/json'] + assert operation.consumes == ['application/json'] + assert operation.security == [{'oauth': ['uid']}] + + expected_body_schema = {'definitions': DEFINITIONS} + expected_body_schema.update(DEFINITIONS["new_stack"]) + assert operation.body_schema == expected_body_schema + + +def test_multiple_security_schemes_and(api): + """Tests an operation with multiple security schemes in AND fashion.""" + def return_api_key_name(func, in_, name): + return name + verify_api_key = mock.MagicMock(side_effect=return_api_key_name) + api.security_handler_factory.verify_api_key = verify_api_key + verify_multiple = mock.MagicMock(return_value='verify_multiple_result') + api.security_handler_factory.verify_multiple_schemes = verify_multiple + + op_spec = make_operation(OPERATION9) + operation = Swagger2Operation(api=api, + method='GET', + path='endpoint', + path_parameters=[], + operation=op_spec, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=SECURITY_DEFINITIONS_2_KEYS, + security_definitions=SECURITY_DEFINITIONS_2_KEYS, + definitions=DEFINITIONS, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=Resolver()) + assert isinstance(operation.function, types.FunctionType) + assert verify_api_key.call_count == 2 + verify_api_key.assert_any_call(math.ceil, 'header', 'X-Auth-1') + verify_api_key.assert_any_call(math.ceil, 'header', 'X-Auth-2') + # Assert verify_multiple_schemes is called with mapping from scheme name + # to result of security_handler_factory.verify_api_key() + verify_multiple.assert_called_with({'key1': 'X-Auth-1', 'key2': 'X-Auth-2'}) + + security_decorator = operation.security_decorator + assert len(security_decorator.args[0]) == 1 + assert security_decorator.args[0][0] == 'verify_multiple_result' + + assert operation.method == 'GET' + assert operation.produces == ['application/json'] + assert operation.consumes == ['application/json'] + assert operation.security == [{'key1': [], 'key2': []}] + + +def test_multiple_oauth_in_and(api, caplog): + """Tests an operation with multiple oauth security schemes in AND fashion. + These should be ignored and raise a warning. + """ + caplog.set_level(logging.WARNING, logger="firetail.operations.secure") + verify_oauth = mock.MagicMock(return_value='verify_oauth_result') + api.security_handler_factory.verify_oauth = verify_oauth + + op_spec = make_operation(OPERATION10) + operation = Swagger2Operation(api=api, + method='GET', + path='endpoint', + path_parameters=[], + operation=op_spec, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=SECURITY_DEFINITIONS_2_OAUTH, + security_definitions=SECURITY_DEFINITIONS_2_OAUTH, + definitions=DEFINITIONS, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=Resolver()) + assert isinstance(operation.function, types.FunctionType) + + security_decorator = operation.security_decorator + assert len(security_decorator.args[0]) == 0 + assert security_decorator.args[0] == [] + + assert '... multiple OAuth2 security schemes in AND fashion not supported' in caplog.text + + assert operation.method == 'GET' + assert operation.produces == ['application/json'] + assert operation.consumes == ['application/json'] + assert operation.security == [{'oauth_1': ['uid'], 'oauth_2': ['uid']}] + + +def test_parameter_reference(api): + op_spec = make_operation(OPERATION3, definitions=False) + operation = Swagger2Operation(api=api, + method='GET', + path='endpoint', + path_parameters=[], + operation=op_spec, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=Resolver()) + assert operation.parameters == [{'in': 'path', 'type': 'integer'}] + + +def test_default(api): + op_spec = make_operation(OPERATION4) + op_spec['parameters'][1]['default'] = 1 + Swagger2Operation( + api=api, method='GET', path='endpoint', path_parameters=[], + operation=op_spec, app_produces=['application/json'], + app_consumes=['application/json'], app_security=[], + security_definitions={}, definitions=DEFINITIONS, + parameter_definitions=PARAMETER_DEFINITIONS, resolver=Resolver() + ) + op_spec = make_operation(OPERATION6, parameters=False) + op_spec['parameters'][0]['default'] = { + 'keep_stacks': 1, + 'image_version': 'one', + 'senza_yaml': 'senza.yaml', + 'new_traffic': 100 + } + Swagger2Operation( + api=api, method='POST', path='endpoint', path_parameters=[], + operation=op_spec, app_produces=['application/json'], + app_consumes=['application/json'], app_security=[], + security_definitions={}, definitions=DEFINITIONS, + parameter_definitions={}, resolver=Resolver() + ) + + +def test_get_path_parameter_types(api): + op_spec = make_operation(OPERATION1, parameters=False) + op_spec['parameters'] = [ + {'in': 'path', 'type': 'int', 'name': 'int_path'}, + {'in': 'path', 'type': 'string', 'name': 'string_path'}, + {'in': 'path', 'type': 'string', 'format': 'path', 'name': 'path_path'} + ] + + operation = Swagger2Operation( + api=api, method='GET', path='endpoint', path_parameters=[], + operation=op_spec, app_produces=['application/json'], + app_consumes=['application/json'], + definitions=DEFINITIONS, resolver=Resolver() + ) + + assert {'int_path': 'int', 'string_path': 'string', 'path_path': 'path'} == operation.get_path_parameter_types() + + +def test_oauth_scopes_in_or(api): + """Tests whether an OAuth security scheme with 2 different possible scopes is correctly handled.""" + verify_oauth = mock.MagicMock(return_value='verify_oauth_result') + api.security_handler_factory.verify_oauth = verify_oauth + + op_spec = make_operation(OPERATION11) + operation = Swagger2Operation(api=api, + method='GET', + path='endpoint', + path_parameters=[], + operation=op_spec, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions=SECURITY_DEFINITIONS_LOCAL, + definitions=DEFINITIONS, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=Resolver()) + assert isinstance(operation.function, types.FunctionType) + security_decorator = operation.security_decorator + assert len(security_decorator.args[0]) == 2 + assert security_decorator.args[0][0] == 'verify_oauth_result' + assert security_decorator.args[0][1] == 'verify_oauth_result' + verify_oauth.assert_has_calls([ + mock.call(math.ceil, api.security_handler_factory.validate_scope, ['myscope']), + mock.call(math.ceil, api.security_handler_factory.validate_scope, ['myscope2']), + ]) + + assert operation.method == 'GET' + assert operation.produces == ['application/json'] + assert operation.consumes == ['application/json'] + assert operation.security == [{'oauth': ['myscope']}, {'oauth': ['myscope2']}] diff --git a/tests/test_references.py b/tests/test_references.py index 7dc4c2b..389b689 100644 --- a/tests/test_references.py +++ b/tests/test_references.py @@ -1,114 +1,114 @@ -from unittest import mock - -import pytest -from firetail.apis.flask_api import Jsonifier -from firetail.json_schema import RefResolutionError, resolve_refs - -DEFINITIONS = { - 'new_stack': { - 'required': [ - 'image_version', - 'keep_stacks', - 'new_traffic', - 'senza_yaml' - ], - 'type': 'object', - 'properties': { - 'keep_stacks': { - 'type': 'integer', - 'description': 'Number of older stacks to keep' - }, - 'image_version': { - 'type': 'string', - 'description': 'Docker image version to deploy' - }, - 'senza_yaml': { - 'type': 'string', - 'description': 'YAML to provide to senza' - }, - 'new_traffic': { - 'type': 'integer', - 'description': 'Percentage of the traffic' - } - } - }, - 'composed': { - 'required': ['test'], - 'type': 'object', - 'properties': { - 'test': { - 'schema': {'$ref': '#/definitions/new_stack'} - } - } - }, - 'problem': {"some": "thing"} -} -PARAMETER_DEFINITIONS = {'myparam': {'in': 'path', 'type': 'integer'}} - - -@pytest.fixture -def api(): - return mock.MagicMock(jsonifier=Jsonifier) - - -def test_non_existent_reference(api): - op_spec = { - 'parameters': [{ - 'in': 'body', - 'name': 'new_stack', - 'required': True, - 'schema': {'$ref': '#/definitions/new_stack'} - }] - } - with pytest.raises(RefResolutionError) as exc_info: # type: py.code.ExceptionInfo - resolve_refs(op_spec, {}) - - exception = exc_info.value - assert "definitions/new_stack" in str(exception) - - -def test_invalid_reference(api): - op_spec = { - 'parameters': [{ - 'in': 'body', - 'name': 'new_stack', - 'required': True, - 'schema': {'$ref': '#/notdefinitions/new_stack'} - }] - } - - with pytest.raises(RefResolutionError) as exc_info: # type: py.code.ExceptionInfo - resolve_refs(op_spec, { - "definitions": DEFINITIONS, - "parameters": PARAMETER_DEFINITIONS - }) - - exception = exc_info.value - assert "notdefinitions/new_stack" in str(exception) - - -def test_resolve_invalid_reference(api): - op_spec = { - 'operationId': 'fakeapi.hello.post_greeting', - 'parameters': [{'$ref': '/parameters/fail'}] - } - - with pytest.raises(RefResolutionError) as exc_info: - resolve_refs(op_spec, { - "parameters": PARAMETER_DEFINITIONS - }) - - exception = exc_info.value - assert "parameters/fail" in str(exception) - - -def test_resolve_web_reference(api): - op_spec = { - 'parameters': [{'$ref': 'https://reallyfake.asd/parameters.json'}] - } - store = { - "https://reallyfake.asd/parameters.json": {"name": "test"} - } - - spec = resolve_refs(op_spec, store=store) - assert spec["parameters"][0]["name"] == "test" +from unittest import mock + +import pytest +from firetail.apis.flask_api import Jsonifier +from firetail.json_schema import RefResolutionError, resolve_refs + +DEFINITIONS = { + 'new_stack': { + 'required': [ + 'image_version', + 'keep_stacks', + 'new_traffic', + 'senza_yaml' + ], + 'type': 'object', + 'properties': { + 'keep_stacks': { + 'type': 'integer', + 'description': 'Number of older stacks to keep' + }, + 'image_version': { + 'type': 'string', + 'description': 'Docker image version to deploy' + }, + 'senza_yaml': { + 'type': 'string', + 'description': 'YAML to provide to senza' + }, + 'new_traffic': { + 'type': 'integer', + 'description': 'Percentage of the traffic' + } + } + }, + 'composed': { + 'required': ['test'], + 'type': 'object', + 'properties': { + 'test': { + 'schema': {'$ref': '#/definitions/new_stack'} + } + } + }, + 'problem': {"some": "thing"} +} +PARAMETER_DEFINITIONS = {'myparam': {'in': 'path', 'type': 'integer'}} + + +@pytest.fixture +def api(): + return mock.MagicMock(jsonifier=Jsonifier) + + +def test_non_existent_reference(api): + op_spec = { + 'parameters': [{ + 'in': 'body', + 'name': 'new_stack', + 'required': True, + 'schema': {'$ref': '#/definitions/new_stack'} + }] + } + with pytest.raises(RefResolutionError) as exc_info: # type: py.code.ExceptionInfo + resolve_refs(op_spec, {}) + + exception = exc_info.value + assert "definitions/new_stack" in str(exception) + + +def test_invalid_reference(api): + op_spec = { + 'parameters': [{ + 'in': 'body', + 'name': 'new_stack', + 'required': True, + 'schema': {'$ref': '#/notdefinitions/new_stack'} + }] + } + + with pytest.raises(RefResolutionError) as exc_info: # type: py.code.ExceptionInfo + resolve_refs(op_spec, { + "definitions": DEFINITIONS, + "parameters": PARAMETER_DEFINITIONS + }) + + exception = exc_info.value + assert "notdefinitions/new_stack" in str(exception) + + +def test_resolve_invalid_reference(api): + op_spec = { + 'operationId': 'fakeapi.hello.post_greeting', + 'parameters': [{'$ref': '/parameters/fail'}] + } + + with pytest.raises(RefResolutionError) as exc_info: + resolve_refs(op_spec, { + "parameters": PARAMETER_DEFINITIONS + }) + + exception = exc_info.value + assert "parameters/fail" in str(exception) + + +def test_resolve_web_reference(api): + op_spec = { + 'parameters': [{'$ref': 'https://reallyfake.asd/parameters.json'}] + } + store = { + "https://reallyfake.asd/parameters.json": {"name": "test"} + } + + spec = resolve_refs(op_spec, store=store) + assert spec["parameters"][0]["name"] == "test" diff --git a/tests/test_resolver.py b/tests/test_resolver.py index c67c881..2565437 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -1,340 +1,333 @@ -import firetail.apps -import pytest -from firetail.exceptions import ResolverError -from firetail.operations import Swagger2Operation -from firetail.resolver import RelativeResolver, Resolver, RestyResolver - -PARAMETER_DEFINITIONS = {'myparam': {'in': 'path', 'type': 'integer'}} - - -def test_standard_get_function(): - function = Resolver().resolve_function_from_operation_id( - 'firetail.FlaskApp.common_error_handler') - assert function == firetail.FlaskApp.common_error_handler - - -def test_relative_get_function(): - function = RelativeResolver('firetail').resolve_function_from_operation_id( - 'firetail.FlaskApp.common_error_handler') - assert function == firetail.FlaskApp.common_error_handler - - -def test_resty_get_function(): - function = RestyResolver('firetail').resolve_function_from_operation_id( - 'firetail.FlaskApp.common_error_handler') - assert function == firetail.FlaskApp.common_error_handler - - -def test_missing_operation_id(): - # Missing operationIDs should result in a well-defined error that can - # be handled upstream. - with pytest.raises(ResolverError): - Resolver().resolve_function_from_operation_id(None) - with pytest.raises(ResolverError): - RelativeResolver('firetail').resolve_function_from_operation_id(None) - with pytest.raises(ResolverError): - RestyResolver('firetail').resolve_function_from_operation_id(None) - - -def test_bad_operation_id(): - # Unresolvable operationIDs should result in a well-defined error that can - # be handled upstream. - with pytest.raises(ResolverError): - Resolver().resolve_function_from_operation_id('ohai.I.do.not.exist') - with pytest.raises(ResolverError): - RelativeResolver('firetail').resolve_function_from_operation_id( - 'ohai.I.do.not.exist') - with pytest.raises(ResolverError): - RestyResolver('firetail').resolve_function_from_operation_id( - 'ohai.I.do.not.exist') - - -def test_standard_resolve_x_router_controller(): - operation = Swagger2Operation(api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'x-swagger-router-controller': 'fakeapi.hello', - 'operationId': 'post_greeting', - }, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=Resolver()) - assert operation.operation_id == 'fakeapi.hello.post_greeting' - - -def test_relative_resolve_x_router_controller(): - operation = Swagger2Operation(api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'x-swagger-router-controller': 'fakeapi.hello', - 'operationId': 'post_greeting', - }, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=RelativeResolver('root_path')) - assert operation.operation_id == 'fakeapi.hello.post_greeting' - - -def test_relative_resolve_operation_id(): - operation = Swagger2Operation(api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'operationId': 'hello.post_greeting', - }, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=RelativeResolver('fakeapi')) - assert operation.operation_id == 'fakeapi.hello.post_greeting' - - -def test_relative_resolve_operation_id_with_module(): - import fakeapi - operation = Swagger2Operation(api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'operationId': 'hello.post_greeting', - }, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=RelativeResolver(fakeapi)) - assert operation.operation_id == 'fakeapi.hello.post_greeting' - - -def test_resty_resolve_operation_id(): - operation = Swagger2Operation(api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'operationId': 'fakeapi.hello.post_greeting', - }, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=RestyResolver('fakeapi')) - assert operation.operation_id == 'fakeapi.hello.post_greeting' - - -def test_resty_resolve_x_router_controller_with_operation_id(): - operation = Swagger2Operation(api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'x-swagger-router-controller': 'fakeapi.hello', - 'operationId': 'post_greeting', - }, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=RestyResolver('fakeapi')) - assert operation.operation_id == 'fakeapi.hello.post_greeting' - - -def test_resty_resolve_x_router_controller_without_operation_id(): - operation = Swagger2Operation(api=None, - method='GET', - path='/hello/{id}', - path_parameters=[], - operation={ - 'x-swagger-router-controller': 'fakeapi.hello'}, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=RestyResolver('fakeapi')) - assert operation.operation_id == 'fakeapi.hello.get' - - -def test_resty_resolve_with_default_module_name(): - operation = Swagger2Operation(api=None, - method='GET', - path='/hello/{id}', - path_parameters=[], - operation={}, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=RestyResolver('fakeapi')) - assert operation.operation_id == 'fakeapi.hello.get' - - -def test_resty_resolve_with_default_module_name_nested(): - operation = Swagger2Operation(api=None, - method='GET', - path='/hello/{id}/world', - path_parameters=[], - operation={}, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=RestyResolver('fakeapi')) - assert operation.operation_id == 'fakeapi.hello.world.search' - - -def test_resty_resolve_with_default_module_name_lowercase_verb(): - operation = Swagger2Operation(api=None, - method='get', - path='/hello/{id}', - path_parameters=[], - operation={}, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=RestyResolver('fakeapi')) - assert operation.operation_id == 'fakeapi.hello.get' - - -def test_resty_resolve_with_default_module_name_lowercase_verb_nested(): - operation = Swagger2Operation(api=None, - method='get', - path='/hello/world/{id}', - path_parameters=[], - operation={}, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=RestyResolver('fakeapi')) - assert operation.operation_id == 'fakeapi.hello.world.get' - - -def test_resty_resolve_with_default_module_name_will_translate_dashes_in_resource_name(): - operation = Swagger2Operation(api=None, - method='GET', - path='/foo-bar', - path_parameters=[], - operation={}, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=RestyResolver('fakeapi')) - assert operation.operation_id == 'fakeapi.foo_bar.search' - - -def test_resty_resolve_with_default_module_name_can_resolve_api_root(): - operation = Swagger2Operation(api=None, - method='GET', - path='/', - path_parameters=[], - operation={}, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=RestyResolver('fakeapi')) - assert operation.operation_id == 'fakeapi.get' - - -def test_resty_resolve_with_default_module_name_will_resolve_resource_root_get_as_search(): - operation = Swagger2Operation(api=None, - method='GET', - path='/hello', - path_parameters=[], - operation={}, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=RestyResolver('fakeapi')) - assert operation.operation_id == 'fakeapi.hello.search' - - -def test_resty_resolve_with_default_module_name_and_x_router_controller_will_resolve_resource_root_get_as_search(): - operation = Swagger2Operation(api=None, - method='GET', - path='/hello', - path_parameters=[], - operation={ - 'x-swagger-router-controller': 'fakeapi.hello', - }, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=RestyResolver('fakeapi')) - assert operation.operation_id == 'fakeapi.hello.search' - - -def test_resty_resolve_with_default_module_name_will_resolve_resource_root_as_configured(): - operation = Swagger2Operation(api=None, - method='GET', - path='/hello', - path_parameters=[], - operation={}, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=RestyResolver('fakeapi', 'api_list')) - assert operation.operation_id == 'fakeapi.hello.api_list' - - -def test_resty_resolve_with_default_module_name_will_resolve_resource_root_post_as_post(): - operation = Swagger2Operation(api=None, - method='POST', - path='/hello', - path_parameters=[], - operation={}, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions={}, - definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=RestyResolver('fakeapi')) - assert operation.operation_id == 'fakeapi.hello.post' +import firetail.apps +import pytest +from firetail.exceptions import ResolverError +from firetail.operations import Swagger2Operation +from firetail.resolver import RelativeResolver, Resolver, RestyResolver + +PARAMETER_DEFINITIONS = {'myparam': {'in': 'path', 'type': 'integer'}} + + +def test_standard_get_function(): + function = Resolver().resolve_function_from_operation_id('firetail.FlaskApp.common_error_handler') + assert function == firetail.FlaskApp.common_error_handler + + +def test_relative_get_function(): + function = RelativeResolver('firetail').resolve_function_from_operation_id('firetail.FlaskApp.common_error_handler') + assert function == firetail.FlaskApp.common_error_handler + + +def test_resty_get_function(): + function = RestyResolver('firetail').resolve_function_from_operation_id('firetail.FlaskApp.common_error_handler') + assert function == firetail.FlaskApp.common_error_handler + + +def test_missing_operation_id(): + # Missing operationIDs should result in a well-defined error that can + # be handled upstream. + with pytest.raises(ResolverError): + Resolver().resolve_function_from_operation_id(None) + with pytest.raises(ResolverError): + RelativeResolver('firetail').resolve_function_from_operation_id(None) + with pytest.raises(ResolverError): + RestyResolver('firetail').resolve_function_from_operation_id(None) + + +def test_bad_operation_id(): + # Unresolvable operationIDs should result in a well-defined error that can + # be handled upstream. + with pytest.raises(ResolverError): + Resolver().resolve_function_from_operation_id('ohai.I.do.not.exist') + with pytest.raises(ResolverError): + RelativeResolver('firetail').resolve_function_from_operation_id('ohai.I.do.not.exist') + with pytest.raises(ResolverError): + RestyResolver('firetail').resolve_function_from_operation_id('ohai.I.do.not.exist') + + +def test_standard_resolve_x_router_controller(): + operation = Swagger2Operation(api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'x-swagger-router-controller': 'fakeapi.hello', + 'operationId': 'post_greeting', + }, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=Resolver()) + assert operation.operation_id == 'fakeapi.hello.post_greeting' + + +def test_relative_resolve_x_router_controller(): + operation = Swagger2Operation(api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'x-swagger-router-controller': 'fakeapi.hello', + 'operationId': 'post_greeting', + }, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=RelativeResolver('root_path')) + assert operation.operation_id == 'fakeapi.hello.post_greeting' + + +def test_relative_resolve_operation_id(): + operation = Swagger2Operation(api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'operationId': 'hello.post_greeting', + }, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=RelativeResolver('fakeapi')) + assert operation.operation_id == 'fakeapi.hello.post_greeting' + + +def test_relative_resolve_operation_id_with_module(): + import fakeapi + operation = Swagger2Operation(api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'operationId': 'hello.post_greeting', + }, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=RelativeResolver(fakeapi)) + assert operation.operation_id == 'fakeapi.hello.post_greeting' + + +def test_resty_resolve_operation_id(): + operation = Swagger2Operation(api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'operationId': 'fakeapi.hello.post_greeting', + }, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=RestyResolver('fakeapi')) + assert operation.operation_id == 'fakeapi.hello.post_greeting' + + +def test_resty_resolve_x_router_controller_with_operation_id(): + operation = Swagger2Operation(api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'x-swagger-router-controller': 'fakeapi.hello', + 'operationId': 'post_greeting', + }, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=RestyResolver('fakeapi')) + assert operation.operation_id == 'fakeapi.hello.post_greeting' + + +def test_resty_resolve_x_router_controller_without_operation_id(): + operation = Swagger2Operation(api=None, + method='GET', + path='/hello/{id}', + path_parameters=[], + operation={'x-swagger-router-controller': 'fakeapi.hello'}, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=RestyResolver('fakeapi')) + assert operation.operation_id == 'fakeapi.hello.get' + + +def test_resty_resolve_with_default_module_name(): + operation = Swagger2Operation(api=None, + method='GET', + path='/hello/{id}', + path_parameters=[], + operation={}, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=RestyResolver('fakeapi')) + assert operation.operation_id == 'fakeapi.hello.get' + + +def test_resty_resolve_with_default_module_name_nested(): + operation = Swagger2Operation(api=None, + method='GET', + path='/hello/{id}/world', + path_parameters=[], + operation={}, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=RestyResolver('fakeapi')) + assert operation.operation_id == 'fakeapi.hello.world.search' + + +def test_resty_resolve_with_default_module_name_lowercase_verb(): + operation = Swagger2Operation(api=None, + method='get', + path='/hello/{id}', + path_parameters=[], + operation={}, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=RestyResolver('fakeapi')) + assert operation.operation_id == 'fakeapi.hello.get' + +def test_resty_resolve_with_default_module_name_lowercase_verb_nested(): + operation = Swagger2Operation(api=None, + method='get', + path='/hello/world/{id}', + path_parameters=[], + operation={}, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=RestyResolver('fakeapi')) + assert operation.operation_id == 'fakeapi.hello.world.get' + + +def test_resty_resolve_with_default_module_name_will_translate_dashes_in_resource_name(): + operation = Swagger2Operation(api=None, + method='GET', + path='/foo-bar', + path_parameters=[], + operation={}, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=RestyResolver('fakeapi')) + assert operation.operation_id == 'fakeapi.foo_bar.search' + + +def test_resty_resolve_with_default_module_name_can_resolve_api_root(): + operation = Swagger2Operation(api=None, + method='GET', + path='/', + path_parameters=[], + operation={}, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=RestyResolver('fakeapi')) + assert operation.operation_id == 'fakeapi.get' + + +def test_resty_resolve_with_default_module_name_will_resolve_resource_root_get_as_search(): + operation = Swagger2Operation(api=None, + method='GET', + path='/hello', + path_parameters=[], + operation={}, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=RestyResolver('fakeapi')) + assert operation.operation_id == 'fakeapi.hello.search' + + +def test_resty_resolve_with_default_module_name_and_x_router_controller_will_resolve_resource_root_get_as_search(): + operation = Swagger2Operation(api=None, + method='GET', + path='/hello', + path_parameters=[], + operation={ + 'x-swagger-router-controller': 'fakeapi.hello', + }, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=RestyResolver('fakeapi')) + assert operation.operation_id == 'fakeapi.hello.search' + + +def test_resty_resolve_with_default_module_name_will_resolve_resource_root_as_configured(): + operation = Swagger2Operation(api=None, + method='GET', + path='/hello', + path_parameters=[], + operation={}, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=RestyResolver('fakeapi', 'api_list')) + assert operation.operation_id == 'fakeapi.hello.api_list' + + +def test_resty_resolve_with_default_module_name_will_resolve_resource_root_post_as_post(): + operation = Swagger2Operation(api=None, + method='POST', + path='/hello', + path_parameters=[], + operation={}, + app_produces=['application/json'], + app_consumes=['application/json'], + app_security=[], + security_definitions={}, + definitions={}, + parameter_definitions=PARAMETER_DEFINITIONS, + resolver=RestyResolver('fakeapi')) + assert operation.operation_id == 'fakeapi.hello.post' diff --git a/tests/test_resolver3.py b/tests/test_resolver3.py index b000e37..54f0617 100644 --- a/tests/test_resolver3.py +++ b/tests/test_resolver3.py @@ -1,260 +1,259 @@ -from firetail.operations import OpenAPIOperation -from firetail.resolver import RelativeResolver, Resolver, RestyResolver - -COMPONENTS = {'parameters': {'myparam': { - 'in': 'path', 'schema': {'type': 'integer'}}}} - - -def test_standard_resolve_x_router_controller(): - operation = OpenAPIOperation( - api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'x-openapi-router-controller': 'fakeapi.hello', - 'operationId': 'post_greeting', - }, - app_security=[], - components=COMPONENTS, - resolver=Resolver() - ) - assert operation.operation_id == 'fakeapi.hello.post_greeting' - - -def test_relative_resolve_x_router_controller(): - operation = OpenAPIOperation( - api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'x-openapi-router-controller': 'fakeapi.hello', - 'operationId': 'post_greeting', - }, - app_security=[], - components=COMPONENTS, - resolver=RelativeResolver('root_path') - ) - assert operation.operation_id == 'fakeapi.hello.post_greeting' - - -def test_relative_resolve_operation_id(): - operation = OpenAPIOperation( - api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'operationId': 'hello.post_greeting', - }, - app_security=[], - components=COMPONENTS, - resolver=RelativeResolver('fakeapi') - ) - assert operation.operation_id == 'fakeapi.hello.post_greeting' - - -def test_relative_resolve_operation_id_with_module(): - import fakeapi - operation = OpenAPIOperation( - api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'operationId': 'hello.post_greeting', - }, - app_security=[], - components=COMPONENTS, - resolver=RelativeResolver(fakeapi) - ) - assert operation.operation_id == 'fakeapi.hello.post_greeting' - - -def test_resty_resolve_operation_id(): - operation = OpenAPIOperation( - api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'operationId': 'fakeapi.hello.post_greeting', - }, - app_security=[], - components=COMPONENTS, - resolver=RestyResolver('fakeapi') - ) - assert operation.operation_id == 'fakeapi.hello.post_greeting' - - -def test_resty_resolve_x_router_controller_with_operation_id(): - operation = OpenAPIOperation( - api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'x-openapi-router-controller': 'fakeapi.hello', - 'operationId': 'post_greeting', - }, - app_security=[], - components=COMPONENTS, - resolver=RestyResolver('fakeapi') - ) - assert operation.operation_id == 'fakeapi.hello.post_greeting' - - -def test_resty_resolve_x_router_controller_without_operation_id(): - operation = OpenAPIOperation( - api=None, - method='GET', - path='/hello/{id}', - path_parameters=[], - operation={'x-openapi-router-controller': 'fakeapi.hello'}, - app_security=[], - components=COMPONENTS, - resolver=RestyResolver('fakeapi')) - assert operation.operation_id == 'fakeapi.hello.get' - - -def test_resty_resolve_with_default_module_name(): - operation = OpenAPIOperation( - api=None, - method='GET', - path='/hello/{id}', - path_parameters=[], - operation={}, - app_security=[], - components=COMPONENTS, - resolver=RestyResolver('fakeapi') - ) - assert operation.operation_id == 'fakeapi.hello.get' - - -def test_resty_resolve_with_default_module_name(): - operation = OpenAPIOperation( - api=None, - method='GET', - path='/hello/{id}/world', - path_parameters=[], - operation={}, - app_security=[], - components=COMPONENTS, - resolver=RestyResolver('fakeapi') - ) - assert operation.operation_id == 'fakeapi.hello.world.search' - - -def test_resty_resolve_with_default_module_name_lowercase_verb(): - operation = OpenAPIOperation( - api=None, - method='get', - path='/hello/{id}', - path_parameters=[], - operation={}, - app_security=[], - components=COMPONENTS, - resolver=RestyResolver('fakeapi') - ) - assert operation.operation_id == 'fakeapi.hello.get' - - -def test_resty_resolve_with_default_module_name_lowercase_verb_nested(): - operation = OpenAPIOperation( - api=None, - method='get', - path='/hello/world/{id}', - path_parameters=[], - operation={}, - app_security=[], - components=COMPONENTS, - resolver=RestyResolver('fakeapi') - ) - assert operation.operation_id == 'fakeapi.hello.world.get' - - -def test_resty_resolve_with_default_module_name_will_translate_dashes_in_resource_name(): - operation = OpenAPIOperation( - api=None, - method='GET', - path='/foo-bar', - path_parameters=[], - operation={}, - app_security=[], - components=COMPONENTS, - resolver=RestyResolver('fakeapi') - ) - assert operation.operation_id == 'fakeapi.foo_bar.search' - - -def test_resty_resolve_with_default_module_name_can_resolve_api_root(): - operation = OpenAPIOperation( - api=None, - method='GET', - path='/', - path_parameters=[], - operation={}, - app_security=[], - components=COMPONENTS, - resolver=RestyResolver('fakeapi') - ) - assert operation.operation_id == 'fakeapi.get' - - -def test_resty_resolve_with_default_module_name_will_resolve_resource_root_get_as_search(): - operation = OpenAPIOperation( - api=None, - method='GET', - path='/hello', - path_parameters=[], - operation={}, - app_security=[], - components=COMPONENTS, - resolver=RestyResolver('fakeapi') - ) - assert operation.operation_id == 'fakeapi.hello.search' - - -def test_resty_resolve_with_default_module_name_and_x_router_controller_will_resolve_resource_root_get_as_search(): - operation = OpenAPIOperation( - api=None, - method='GET', - path='/hello', - path_parameters=[], - operation={ - 'x-openapi-router-controller': 'fakeapi.hello', - }, - app_security=[], - components=COMPONENTS, - resolver=RestyResolver('fakeapi') - ) - assert operation.operation_id == 'fakeapi.hello.search' - - -def test_resty_resolve_with_default_module_name_will_resolve_resource_root_as_configured(): - operation = OpenAPIOperation( - api=None, - method='GET', - path='/hello', - path_parameters=[], - operation={}, - app_security=[], - components=COMPONENTS, - resolver=RestyResolver('fakeapi', 'api_list') - ) - assert operation.operation_id == 'fakeapi.hello.api_list' - - -def test_resty_resolve_with_default_module_name_will_resolve_resource_root_post_as_post(): - operation = OpenAPIOperation( - api=None, - method='POST', - path='/hello', - path_parameters=[], - operation={}, - app_security=[], - components=COMPONENTS, - resolver=RestyResolver('fakeapi') - ) - assert operation.operation_id == 'fakeapi.hello.post' +from firetail.operations import OpenAPIOperation +from firetail.resolver import RelativeResolver, Resolver, RestyResolver + +COMPONENTS = {'parameters': {'myparam': {'in': 'path', 'schema': {'type': 'integer'}}}} + + +def test_standard_resolve_x_router_controller(): + operation = OpenAPIOperation( + api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'x-openapi-router-controller': 'fakeapi.hello', + 'operationId': 'post_greeting', + }, + app_security=[], + components=COMPONENTS, + resolver=Resolver() + ) + assert operation.operation_id == 'fakeapi.hello.post_greeting' + + +def test_relative_resolve_x_router_controller(): + operation = OpenAPIOperation( + api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'x-openapi-router-controller': 'fakeapi.hello', + 'operationId': 'post_greeting', + }, + app_security=[], + components=COMPONENTS, + resolver=RelativeResolver('root_path') + ) + assert operation.operation_id == 'fakeapi.hello.post_greeting' + + +def test_relative_resolve_operation_id(): + operation = OpenAPIOperation( + api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'operationId': 'hello.post_greeting', + }, + app_security=[], + components=COMPONENTS, + resolver=RelativeResolver('fakeapi') + ) + assert operation.operation_id == 'fakeapi.hello.post_greeting' + + +def test_relative_resolve_operation_id_with_module(): + import fakeapi + operation = OpenAPIOperation( + api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'operationId': 'hello.post_greeting', + }, + app_security=[], + components=COMPONENTS, + resolver=RelativeResolver(fakeapi) + ) + assert operation.operation_id == 'fakeapi.hello.post_greeting' + + +def test_resty_resolve_operation_id(): + operation = OpenAPIOperation( + api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'operationId': 'fakeapi.hello.post_greeting', + }, + app_security=[], + components=COMPONENTS, + resolver=RestyResolver('fakeapi') + ) + assert operation.operation_id == 'fakeapi.hello.post_greeting' + + +def test_resty_resolve_x_router_controller_with_operation_id(): + operation = OpenAPIOperation( + api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'x-openapi-router-controller': 'fakeapi.hello', + 'operationId': 'post_greeting', + }, + app_security=[], + components=COMPONENTS, + resolver=RestyResolver('fakeapi') + ) + assert operation.operation_id == 'fakeapi.hello.post_greeting' + + +def test_resty_resolve_x_router_controller_without_operation_id(): + operation = OpenAPIOperation( + api=None, + method='GET', + path='/hello/{id}', + path_parameters=[], + operation={'x-openapi-router-controller': 'fakeapi.hello'}, + app_security=[], + components=COMPONENTS, + resolver=RestyResolver('fakeapi')) + assert operation.operation_id == 'fakeapi.hello.get' + + +def test_resty_resolve_with_default_module_name(): + operation = OpenAPIOperation( + api=None, + method='GET', + path='/hello/{id}', + path_parameters=[], + operation={}, + app_security=[], + components=COMPONENTS, + resolver=RestyResolver('fakeapi') + ) + assert operation.operation_id == 'fakeapi.hello.get' + + +def test_resty_resolve_with_default_module_name(): + operation = OpenAPIOperation( + api=None, + method='GET', + path='/hello/{id}/world', + path_parameters=[], + operation={}, + app_security=[], + components=COMPONENTS, + resolver=RestyResolver('fakeapi') + ) + assert operation.operation_id == 'fakeapi.hello.world.search' + + +def test_resty_resolve_with_default_module_name_lowercase_verb(): + operation = OpenAPIOperation( + api=None, + method='get', + path='/hello/{id}', + path_parameters=[], + operation={}, + app_security=[], + components=COMPONENTS, + resolver=RestyResolver('fakeapi') + ) + assert operation.operation_id == 'fakeapi.hello.get' + + +def test_resty_resolve_with_default_module_name_lowercase_verb_nested(): + operation = OpenAPIOperation( + api=None, + method='get', + path='/hello/world/{id}', + path_parameters=[], + operation={}, + app_security=[], + components=COMPONENTS, + resolver=RestyResolver('fakeapi') + ) + assert operation.operation_id == 'fakeapi.hello.world.get' + + +def test_resty_resolve_with_default_module_name_will_translate_dashes_in_resource_name(): + operation = OpenAPIOperation( + api=None, + method='GET', + path='/foo-bar', + path_parameters=[], + operation={}, + app_security=[], + components=COMPONENTS, + resolver=RestyResolver('fakeapi') + ) + assert operation.operation_id == 'fakeapi.foo_bar.search' + + +def test_resty_resolve_with_default_module_name_can_resolve_api_root(): + operation = OpenAPIOperation( + api=None, + method='GET', + path='/', + path_parameters=[], + operation={}, + app_security=[], + components=COMPONENTS, + resolver=RestyResolver('fakeapi') + ) + assert operation.operation_id == 'fakeapi.get' + + +def test_resty_resolve_with_default_module_name_will_resolve_resource_root_get_as_search(): + operation = OpenAPIOperation( + api=None, + method='GET', + path='/hello', + path_parameters=[], + operation={}, + app_security=[], + components=COMPONENTS, + resolver=RestyResolver('fakeapi') + ) + assert operation.operation_id == 'fakeapi.hello.search' + + +def test_resty_resolve_with_default_module_name_and_x_router_controller_will_resolve_resource_root_get_as_search(): + operation = OpenAPIOperation( + api=None, + method='GET', + path='/hello', + path_parameters=[], + operation={ + 'x-openapi-router-controller': 'fakeapi.hello', + }, + app_security=[], + components=COMPONENTS, + resolver=RestyResolver('fakeapi') + ) + assert operation.operation_id == 'fakeapi.hello.search' + + +def test_resty_resolve_with_default_module_name_will_resolve_resource_root_as_configured(): + operation = OpenAPIOperation( + api=None, + method='GET', + path='/hello', + path_parameters=[], + operation={}, + app_security=[], + components=COMPONENTS, + resolver=RestyResolver('fakeapi', 'api_list') + ) + assert operation.operation_id == 'fakeapi.hello.api_list' + + +def test_resty_resolve_with_default_module_name_will_resolve_resource_root_post_as_post(): + operation = OpenAPIOperation( + api=None, + method='POST', + path='/hello', + path_parameters=[], + operation={}, + app_security=[], + components=COMPONENTS, + resolver=RestyResolver('fakeapi') + ) + assert operation.operation_id == 'fakeapi.hello.post' diff --git a/tests/test_resolver_methodview.py b/tests/test_resolver_methodview.py index f87c314..fea281b 100644 --- a/tests/test_resolver_methodview.py +++ b/tests/test_resolver_methodview.py @@ -1,182 +1,179 @@ -from firetail.operations import OpenAPIOperation -from firetail.resolver import MethodViewResolver, Resolver - -COMPONENTS = {'parameters': {'myparam': { - 'in': 'path', 'schema': {'type': 'integer'}}}} - - -def test_standard_resolve_x_router_controller(): - operation = OpenAPIOperation( - api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'x-openapi-router-controller': 'fakeapi.hello', - 'operationId': 'post_greeting', - }, - app_security=[], - components=COMPONENTS, - resolver=Resolver() - ) - assert operation.operation_id == 'fakeapi.hello.post_greeting' - - -def test_methodview_resolve_operation_id(): - operation = OpenAPIOperation( - api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'operationId': 'fakeapi.hello.post_greeting', - }, - app_security=[], - components=COMPONENTS, - resolver=MethodViewResolver('fakeapi') - ) - assert operation.operation_id == 'fakeapi.hello.post_greeting' - - -def test_methodview_resolve_x_router_controller_with_operation_id(): - operation = OpenAPIOperation( - api=None, - method='GET', - path='endpoint', - path_parameters=[], - operation={ - 'x-openapi-router-controller': 'fakeapi.ExampleMethodView', - 'operationId': 'post_greeting', - }, - app_security=[], - components=COMPONENTS, - resolver=MethodViewResolver('fakeapi') - ) - assert operation.operation_id == 'fakeapi.ExampleMethodView.post_greeting' - - -def test_methodview_resolve_x_router_controller_without_operation_id(): - operation = OpenAPIOperation(api=None, - method='GET', - path='/hello/{id}', - path_parameters=[], - operation={ - 'x-openapi-router-controller': 'fakeapi.example_method'}, - app_security=[], - components=COMPONENTS, - resolver=MethodViewResolver('fakeapi')) - assert operation.operation_id == 'fakeapi.ExampleMethodView.get' - - -def test_methodview_resolve_with_default_module_name(): - operation = OpenAPIOperation( - api=None, - method='GET', - path='/example_method/{id}', - path_parameters=[], - operation={}, - app_security=[], - components=COMPONENTS, - resolver=MethodViewResolver('fakeapi') - ) - assert operation.operation_id == 'fakeapi.ExampleMethodView.get' - - -def test_methodview_resolve_with_default_module_name_lowercase_verb(): - operation = OpenAPIOperation( - api=None, - method='get', - path='/example_method/{id}', - path_parameters=[], - operation={}, - app_security=[], - components=COMPONENTS, - resolver=MethodViewResolver('fakeapi') - ) - assert operation.operation_id == 'fakeapi.ExampleMethodView.get' - - -def test_methodview_resolve_with_default_module_name_will_translate_dashes_in_resource_name(): - operation = OpenAPIOperation( - api=None, - method='GET', - path='/example-method', - path_parameters=[], - operation={}, - app_security=[], - components=COMPONENTS, - resolver=MethodViewResolver('fakeapi') - ) - assert operation.operation_id == 'fakeapi.ExampleMethodView.search' - - -def test_methodview_resolve_with_default_module_name_can_resolve_api_root(): - operation = OpenAPIOperation( - api=None, - method='GET', - path='/', - path_parameters=[], - operation={}, - app_security=[], - components=COMPONENTS, - resolver=MethodViewResolver('fakeapi.example_method',) - ) - assert operation.operation_id == 'fakeapi.ExampleMethodView.get' - - -def test_methodview_resolve_with_default_module_name_will_resolve_resource_root_get_as_search(): - operation = OpenAPIOperation( - api=None, - method='GET', - path='/example_method', - path_parameters=[], - operation={}, - app_security=[], - components=COMPONENTS, - resolver=MethodViewResolver('fakeapi') - ) - assert operation.operation_id == 'fakeapi.ExampleMethodView.search' - - -def test_methodview_resolve_with_default_module_name_and_x_router_controller_will_resolve_resource_root_get_as_search(): - operation = OpenAPIOperation( - api=None, - method='GET', - path='/hello', - path_parameters=[], - operation={ - 'x-openapi-router-controller': 'fakeapi.example_method', - }, - app_security=[], - components=COMPONENTS, - resolver=MethodViewResolver('fakeapi') - ) - assert operation.operation_id == 'fakeapi.ExampleMethodView.search' - - -def test_methodview_resolve_with_default_module_name_will_resolve_resource_root_as_configured(): - operation = OpenAPIOperation( - api=None, - method='GET', - path='/example_method', - path_parameters=[], - operation={}, - app_security=[], - components=COMPONENTS, - resolver=MethodViewResolver('fakeapi', 'api_list') - ) - assert operation.operation_id == 'fakeapi.ExampleMethodView.api_list' - - -def test_methodview_resolve_with_default_module_name_will_resolve_resource_root_post_as_post(): - operation = OpenAPIOperation( - api=None, - method='POST', - path='/example_method', - path_parameters=[], - operation={}, - app_security=[], - components=COMPONENTS, - resolver=MethodViewResolver('fakeapi') - ) - assert operation.operation_id == 'fakeapi.ExampleMethodView.post' +from firetail.operations import OpenAPIOperation +from firetail.resolver import MethodViewResolver, Resolver + +COMPONENTS = {'parameters': {'myparam': {'in': 'path', 'schema': {'type': 'integer'}}}} + + +def test_standard_resolve_x_router_controller(): + operation = OpenAPIOperation( + api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'x-openapi-router-controller': 'fakeapi.hello', + 'operationId': 'post_greeting', + }, + app_security=[], + components=COMPONENTS, + resolver=Resolver() + ) + assert operation.operation_id == 'fakeapi.hello.post_greeting' + +def test_methodview_resolve_operation_id(): + operation = OpenAPIOperation( + api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'operationId': 'fakeapi.hello.post_greeting', + }, + app_security=[], + components=COMPONENTS, + resolver=MethodViewResolver('fakeapi') + ) + assert operation.operation_id == 'fakeapi.hello.post_greeting' + + +def test_methodview_resolve_x_router_controller_with_operation_id(): + operation = OpenAPIOperation( + api=None, + method='GET', + path='endpoint', + path_parameters=[], + operation={ + 'x-openapi-router-controller': 'fakeapi.ExampleMethodView', + 'operationId': 'post_greeting', + }, + app_security=[], + components=COMPONENTS, + resolver=MethodViewResolver('fakeapi') + ) + assert operation.operation_id == 'fakeapi.ExampleMethodView.post_greeting' + + +def test_methodview_resolve_x_router_controller_without_operation_id(): + operation = OpenAPIOperation(api=None, + method='GET', + path='/hello/{id}', + path_parameters=[], + operation={'x-openapi-router-controller': 'fakeapi.example_method'}, + app_security=[], + components=COMPONENTS, + resolver=MethodViewResolver('fakeapi')) + assert operation.operation_id == 'fakeapi.ExampleMethodView.get' + + +def test_methodview_resolve_with_default_module_name(): + operation = OpenAPIOperation( + api=None, + method='GET', + path='/example_method/{id}', + path_parameters=[], + operation={}, + app_security=[], + components=COMPONENTS, + resolver=MethodViewResolver('fakeapi') + ) + assert operation.operation_id == 'fakeapi.ExampleMethodView.get' + + +def test_methodview_resolve_with_default_module_name_lowercase_verb(): + operation = OpenAPIOperation( + api=None, + method='get', + path='/example_method/{id}', + path_parameters=[], + operation={}, + app_security=[], + components=COMPONENTS, + resolver=MethodViewResolver('fakeapi') + ) + assert operation.operation_id == 'fakeapi.ExampleMethodView.get' + + +def test_methodview_resolve_with_default_module_name_will_translate_dashes_in_resource_name(): + operation = OpenAPIOperation( + api=None, + method='GET', + path='/example-method', + path_parameters=[], + operation={}, + app_security=[], + components=COMPONENTS, + resolver=MethodViewResolver('fakeapi') + ) + assert operation.operation_id == 'fakeapi.ExampleMethodView.search' + + +def test_methodview_resolve_with_default_module_name_can_resolve_api_root(): + operation = OpenAPIOperation( + api=None, + method='GET', + path='/', + path_parameters=[], + operation={}, + app_security=[], + components=COMPONENTS, + resolver=MethodViewResolver('fakeapi.example_method',) + ) + assert operation.operation_id == 'fakeapi.ExampleMethodView.get' + + +def test_methodview_resolve_with_default_module_name_will_resolve_resource_root_get_as_search(): + operation = OpenAPIOperation( + api=None, + method='GET', + path='/example_method', + path_parameters=[], + operation={}, + app_security=[], + components=COMPONENTS, + resolver=MethodViewResolver('fakeapi') + ) + assert operation.operation_id == 'fakeapi.ExampleMethodView.search' + + +def test_methodview_resolve_with_default_module_name_and_x_router_controller_will_resolve_resource_root_get_as_search(): + operation = OpenAPIOperation( + api=None, + method='GET', + path='/hello', + path_parameters=[], + operation={ + 'x-openapi-router-controller': 'fakeapi.example_method', + }, + app_security=[], + components=COMPONENTS, + resolver=MethodViewResolver('fakeapi') + ) + assert operation.operation_id == 'fakeapi.ExampleMethodView.search' + + +def test_methodview_resolve_with_default_module_name_will_resolve_resource_root_as_configured(): + operation = OpenAPIOperation( + api=None, + method='GET', + path='/example_method', + path_parameters=[], + operation={}, + app_security=[], + components=COMPONENTS, + resolver=MethodViewResolver('fakeapi', 'api_list') + ) + assert operation.operation_id == 'fakeapi.ExampleMethodView.api_list' + + +def test_methodview_resolve_with_default_module_name_will_resolve_resource_root_post_as_post(): + operation = OpenAPIOperation( + api=None, + method='POST', + path='/example_method', + path_parameters=[], + operation={}, + app_security=[], + components=COMPONENTS, + resolver=MethodViewResolver('fakeapi') + ) + assert operation.operation_id == 'fakeapi.ExampleMethodView.post' diff --git a/tests/test_utils.py b/tests/test_utils.py index 7132d03..256ee0f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,63 +1,62 @@ -import math -from unittest.mock import MagicMock - -import firetail.apps -import pytest -from firetail import utils - - -def test_get_function_from_name(): - function = utils.get_function_from_name('math.ceil') - assert function == math.ceil - assert function(2.7) == 3 - - -def test_get_function_from_name_no_module(): - with pytest.raises(ValueError): - utils.get_function_from_name('math') - - -def test_get_function_from_name_attr_error(monkeypatch): - """ - Test attribute error without import error on get_function_from_name. - Attribute errors due to import errors are tested on - test_api.test_invalid_operation_does_stop_application_to_setup - """ - deep_attr_mock = MagicMock() - deep_attr_mock.side_effect = AttributeError - monkeypatch.setattr("firetail.utils.deep_getattr", deep_attr_mock) - with pytest.raises(AttributeError): - utils.get_function_from_name('math.ceil') - - -def test_get_function_from_name_for_class_method(): - function = utils.get_function_from_name( - 'firetail.FlaskApp.common_error_handler') - assert function == firetail.FlaskApp.common_error_handler - - -def test_boolean(): - assert utils.boolean('true') - assert utils.boolean('True') - assert utils.boolean('TRUE') - assert utils.boolean(True) - assert not utils.boolean('false') - assert not utils.boolean('False') - assert not utils.boolean('FALSE') - assert not utils.boolean(False) - - with pytest.raises(ValueError): - utils.boolean('foo') - - with pytest.raises(ValueError): - utils.boolean(None) - - -def test_deep_get_dict(): - obj = {'type': 'object', 'properties': {'id': {'type': 'string'}}} - assert utils.deep_get(obj, ['properties', 'id']) == {'type': 'string'} - - -def test_deep_get_list(): - obj = [{'type': 'object', 'properties': {'id': {'type': 'string'}}}] - assert utils.deep_get(obj, ['0', 'properties', 'id']) == {'type': 'string'} +import math +from unittest.mock import MagicMock + +import firetail.apps +import pytest +from firetail import utils + + +def test_get_function_from_name(): + function = utils.get_function_from_name('math.ceil') + assert function == math.ceil + assert function(2.7) == 3 + + +def test_get_function_from_name_no_module(): + with pytest.raises(ValueError): + utils.get_function_from_name('math') + + +def test_get_function_from_name_attr_error(monkeypatch): + """ + Test attribute error without import error on get_function_from_name. + Attribute errors due to import errors are tested on + test_api.test_invalid_operation_does_stop_application_to_setup + """ + deep_attr_mock = MagicMock() + deep_attr_mock.side_effect = AttributeError + monkeypatch.setattr("firetail.utils.deep_getattr", deep_attr_mock) + with pytest.raises(AttributeError): + utils.get_function_from_name('math.ceil') + + +def test_get_function_from_name_for_class_method(): + function = utils.get_function_from_name('firetail.FlaskApp.common_error_handler') + assert function == firetail.FlaskApp.common_error_handler + + +def test_boolean(): + assert utils.boolean('true') + assert utils.boolean('True') + assert utils.boolean('TRUE') + assert utils.boolean(True) + assert not utils.boolean('false') + assert not utils.boolean('False') + assert not utils.boolean('FALSE') + assert not utils.boolean(False) + + with pytest.raises(ValueError): + utils.boolean('foo') + + with pytest.raises(ValueError): + utils.boolean(None) + + +def test_deep_get_dict(): + obj = {'type': 'object', 'properties': {'id': {'type': 'string'}}} + assert utils.deep_get(obj, ['properties', 'id']) == {'type': 'string'} + + +def test_deep_get_list(): + obj = [{'type': 'object', 'properties': {'id': {'type': 'string'}}}] + assert utils.deep_get(obj, ['0', 'properties', 'id']) == {'type': 'string'} diff --git a/tests/test_validation.py b/tests/test_validation.py index 1657453..ae12c37 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1,104 +1,93 @@ -from unittest.mock import MagicMock - -import flask -import pytest -from firetail.apis.flask_api import FlaskApi -from firetail.decorators.validation import ParameterValidator -from firetail.exceptions import BadRequestProblem - - -def test_parameter_validator(monkeypatch): - request = MagicMock(name='request') - request.args = {} - request.headers = {} - request.cookies = {} - request.params = {} - app = MagicMock(name='app') - - app.response_class = flask.Response - monkeypatch.setattr('flask.request', request) - monkeypatch.setattr('flask.current_app', app) - - def orig_handler(*args, **kwargs): - return 'OK' - - params = [{'name': 'p1', 'in': 'path', 'type': 'integer', 'required': True}, - {'name': 'h1', 'in': 'header', - 'type': 'string', 'enum': ['a', 'b']}, - {'name': 'c1', 'in': 'cookie', - 'type': 'string', 'enum': ['a', 'b']}, - {'name': 'q1', 'in': 'query', 'type': 'integer', 'maximum': 3}, - {'name': 'a1', 'in': 'query', 'type': 'array', 'minItems': 2, 'maxItems': 3, - 'items': {'type': 'integer', 'minimum': 0}}] - validator = ParameterValidator(params, FlaskApi) - handler = validator(orig_handler) - - kwargs = {'query': {}, 'headers': {}, 'cookies': {}} - request = MagicMock(path_params={}, **kwargs) - with pytest.raises(BadRequestProblem) as exc: - handler(request) - assert exc.value.detail == "Missing path parameter 'p1'" - request = MagicMock(path_params={'p1': '123'}, **kwargs) - assert handler(request) == 'OK' - request = MagicMock(path_params={'p1': ''}, **kwargs) - with pytest.raises(BadRequestProblem) as exc: - handler(request) - assert exc.value.detail == "Wrong type, expected 'integer' for path parameter 'p1'" - request = MagicMock(path_params={'p1': 'foo'}, **kwargs) - with pytest.raises(BadRequestProblem) as exc: - handler(request) - assert exc.value.detail == "Wrong type, expected 'integer' for path parameter 'p1'" - request = MagicMock(path_params={'p1': '1.2'}, **kwargs) - with pytest.raises(BadRequestProblem) as exc: - handler(request) - assert exc.value.detail == "Wrong type, expected 'integer' for path parameter 'p1'" - - request = MagicMock(path_params={'p1': 1}, query={'q1': '4'}, headers={}) - with pytest.raises(BadRequestProblem) as exc: - handler(request) - assert exc.value.detail.startswith('4 is greater than the maximum of 3') - request = MagicMock(path_params={'p1': 1}, query={ - 'q1': '3'}, headers={}, cookies={}) - assert handler(request) == 'OK' - - request = MagicMock(path_params={'p1': 1}, query={ - 'a1': ['1', '2']}, headers={}, cookies={}) - assert handler(request) == "OK" - request = MagicMock(path_params={'p1': 1}, query={ - 'a1': ['1', 'a']}, headers={}) - with pytest.raises(BadRequestProblem) as exc: - handler(request) - assert exc.value.detail.startswith("'a' is not of type 'integer'") - request = MagicMock(path_params={'p1': '123'}, query={ - }, headers={}, cookies={'c1': 'b'}) - assert handler(request) == 'OK' - - request = MagicMock(path_params={'p1': '123'}, query={ - }, headers={}, cookies={'c1': 'x'}) - with pytest.raises(BadRequestProblem) as exc: - assert handler(request) - assert exc.value.detail.startswith("'x' is not one of ['a', 'b']") - request = MagicMock(path_params={'p1': 1}, query={ - 'a1': ['1', '-1']}, headers={}) - with pytest.raises(BadRequestProblem) as exc: - handler(request) - assert exc.value.detail.startswith("-1 is less than the minimum of 0") - request = MagicMock(path_params={'p1': 1}, query={'a1': ['1']}, headers={}) - with pytest.raises(BadRequestProblem) as exc: - handler(request) - assert exc.value.detail.startswith("[1] is too short") - request = MagicMock(path_params={'p1': 1}, query={ - 'a1': ['1', '2', '3', '4']}, headers={}) - with pytest.raises(BadRequestProblem) as exc: - handler(request) - assert exc.value.detail.startswith("[1, 2, 3, 4] is too long") - - request = MagicMock(path_params={'p1': '123'}, query={ - }, headers={'h1': 'a'}, cookies={}) - assert handler(request) == 'OK' - - request = MagicMock(path_params={'p1': '123'}, - query={}, headers={'h1': 'x'}) - with pytest.raises(BadRequestProblem) as exc: - handler(request) - assert exc.value.detail.startswith("'x' is not one of ['a', 'b']") +from unittest.mock import MagicMock + +import flask +import pytest +from firetail.apis.flask_api import FlaskApi +from firetail.decorators.validation import ParameterValidator +from firetail.exceptions import BadRequestProblem + + +def test_parameter_validator(monkeypatch): + request = MagicMock(name='request') + request.args = {} + request.headers = {} + request.cookies = {} + request.params = {} + app = MagicMock(name='app') + + app.response_class = flask.Response + monkeypatch.setattr('flask.request', request) + monkeypatch.setattr('flask.current_app', app) + + def orig_handler(*args, **kwargs): + return 'OK' + + params = [{'name': 'p1', 'in': 'path', 'type': 'integer', 'required': True}, + {'name': 'h1', 'in': 'header', 'type': 'string', 'enum': ['a', 'b']}, + {'name': 'c1', 'in': 'cookie', 'type': 'string', 'enum': ['a', 'b']}, + {'name': 'q1', 'in': 'query', 'type': 'integer', 'maximum': 3}, + {'name': 'a1', 'in': 'query', 'type': 'array', 'minItems': 2, 'maxItems': 3, + 'items': {'type': 'integer', 'minimum': 0}}] + validator = ParameterValidator(params, FlaskApi) + handler = validator(orig_handler) + + kwargs = {'query': {}, 'headers': {}, 'cookies': {}} + request = MagicMock(path_params={}, **kwargs) + with pytest.raises(BadRequestProblem) as exc: + handler(request) + assert exc.value.detail == "Missing path parameter 'p1'" + request = MagicMock(path_params={'p1': '123'}, **kwargs) + assert handler(request) == 'OK' + request = MagicMock(path_params={'p1': ''}, **kwargs) + with pytest.raises(BadRequestProblem) as exc: + handler(request) + assert exc.value.detail == "Wrong type, expected 'integer' for path parameter 'p1'" + request = MagicMock(path_params={'p1': 'foo'}, **kwargs) + with pytest.raises(BadRequestProblem) as exc: + handler(request) + assert exc.value.detail == "Wrong type, expected 'integer' for path parameter 'p1'" + request = MagicMock(path_params={'p1': '1.2'}, **kwargs) + with pytest.raises(BadRequestProblem) as exc: + handler(request) + assert exc.value.detail == "Wrong type, expected 'integer' for path parameter 'p1'" + + request = MagicMock(path_params={'p1': 1}, query={'q1': '4'}, headers={}) + with pytest.raises(BadRequestProblem) as exc: + handler(request) + assert exc.value.detail.startswith('4 is greater than the maximum of 3') + request = MagicMock(path_params={'p1': 1}, query={'q1': '3'}, headers={}, cookies={}) + assert handler(request) == 'OK' + + request = MagicMock(path_params={'p1': 1}, query={'a1': ['1', '2']}, headers={}, cookies={}) + assert handler(request) == "OK" + request = MagicMock(path_params={'p1': 1}, query={'a1': ['1', 'a']}, headers={}) + with pytest.raises(BadRequestProblem) as exc: + handler(request) + assert exc.value.detail.startswith("'a' is not of type 'integer'") + request = MagicMock(path_params={'p1': '123'}, query={}, headers={}, cookies={'c1': 'b'}) + assert handler(request) == 'OK' + + request = MagicMock(path_params={'p1': '123'}, query={}, headers={}, cookies={'c1': 'x'}) + with pytest.raises(BadRequestProblem) as exc: + assert handler(request) + assert exc.value.detail.startswith("'x' is not one of ['a', 'b']") + request = MagicMock(path_params={'p1': 1}, query={'a1': ['1', '-1']}, headers={}) + with pytest.raises(BadRequestProblem) as exc: + handler(request) + assert exc.value.detail.startswith("-1 is less than the minimum of 0") + request = MagicMock(path_params={'p1': 1}, query={'a1': ['1']}, headers={}) + with pytest.raises(BadRequestProblem) as exc: + handler(request) + assert exc.value.detail.startswith("[1] is too short") + request = MagicMock(path_params={'p1': 1}, query={'a1': ['1', '2', '3', '4']}, headers={}) + with pytest.raises(BadRequestProblem) as exc: + handler(request) + assert exc.value.detail.startswith("[1, 2, 3, 4] is too long") + + request = MagicMock(path_params={'p1': '123'}, query={}, headers={'h1': 'a'}, cookies={}) + assert handler(request) == 'OK' + + request = MagicMock(path_params={'p1': '123'}, query={}, headers={'h1': 'x'}) + with pytest.raises(BadRequestProblem) as exc: + handler(request) + assert exc.value.detail.startswith("'x' is not one of ['a', 'b']") diff --git a/tox.ini b/tox.ini index c3f4640..919e22a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,14 +1,18 @@ [flake8] -max-line-length=170 exclude=firetail/__init__.py rst-roles=class +max-line-length=137 +extend-ignore=E203 + +[isort] +profile = black [tox] envlist = - ; {py37}-{min,pypi,dev} - ; {py38}-{min,pypi,dev} + {py37}-{min,pypi,dev} + {py38}-{min,pypi,dev} {py39}-{min,pypi,dev} - ; {py310}-{min,pypi,dev} + {py310}-{min,pypi,dev} isort-check isort-check-examples isort-check-tests