diff --git a/.readthedocs.yaml b/.readthedocs.yaml index bf7948d41..1aa8dc2dc 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -10,9 +10,9 @@ build: jobs: post_create_environment: # Install poetry - - pip install poetry + - python -m pip install poetry # Tell poetry to not use a virtual environment - poetry config virtualenvs.create false post_install: # Install dependencies with 'docs' dependency group - - poetry install --with docs --all-extras + - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH python -m poetry install --with docs --all-extras diff --git a/README.md b/README.md index 5d6841a9c..e479ff79b 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ You can install them as follows: ```shell $ pip install connexion[swagger-ui] - $ pip install connexion[swagger-ui,uvicorn]. + $ pip install connexion[swagger-ui,uvicorn] ```

(back to top)

@@ -280,4 +280,4 @@ Tools to help you work spec-first: [Pycharm plugin]: https://plugins.jetbrains.com/plugin/14837-openapi-swagger-editor [examples]: https://github.com/spec-first/connexion/blob/main/examples [Releases]: https://github.com/spec-first/connexion/releases -[Architecture]: https://github.com/spec-first/connexion/blob/main/docs/images/architecture.png \ No newline at end of file +[Architecture]: https://github.com/spec-first/connexion/blob/main/docs/images/architecture.png diff --git a/connexion/mock.py b/connexion/mock.py index 9e69e3130..150bc5d09 100644 --- a/connexion/mock.py +++ b/connexion/mock.py @@ -51,4 +51,9 @@ 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 + return ( + "No example response defined in the API, and response " + "auto-generation disabled. To enable response auto-generation, " + "install connexion using the mock extra (connexion[mock])", + 501, + ) diff --git a/connexion/operations/openapi.py b/connexion/operations/openapi.py index 8ace3f372..a732047c2 100644 --- a/connexion/operations/openapi.py +++ b/connexion/operations/openapi.py @@ -7,7 +7,7 @@ from connexion.datastructures import MediaTypeDict from connexion.operations.abstract import AbstractOperation from connexion.uri_parsing import OpenAPIURIParser -from connexion.utils import deep_get +from connexion.utils import build_example_from_schema, deep_get logger = logging.getLogger("connexion.operations.openapi3") @@ -187,31 +187,11 @@ def example_response(self, status_code=None, content_type=None): pass try: - return ( - self._nested_example(deep_get(self._responses, schema_path)), - status_code, - ) + schema = deep_get(self._responses, schema_path) except KeyError: - return (None, status_code) + return ("No example response or response schema defined.", 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 + return (build_example_from_schema(schema), status_code) def get_path_parameter_types(self): types = {} diff --git a/connexion/operations/swagger2.py b/connexion/operations/swagger2.py index 3dba7aa38..d9e30f012 100644 --- a/connexion/operations/swagger2.py +++ b/connexion/operations/swagger2.py @@ -8,7 +8,7 @@ from connexion.exceptions import InvalidSpecification from connexion.operations.abstract import AbstractOperation from connexion.uri_parsing import Swagger2URIParser -from connexion.utils import deep_get +from connexion.utils import build_example_from_schema, deep_get logger = logging.getLogger("connexion.operations.swagger2") @@ -209,31 +209,11 @@ def example_response(self, status_code=None, *args, **kwargs): pass try: - return ( - self._nested_example(deep_get(self._responses, schema_path)), - status_code, - ) + schema = deep_get(self._responses, schema_path) except KeyError: - return (None, status_code) + return ("No example response or response schema defined.", 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 + return (build_example_from_schema(schema), status_code) def body_name(self, content_type: str = None) -> str: return self.body_definition(content_type).get("name", "body") diff --git a/connexion/security.py b/connexion/security.py index ddfd14b16..e618f7ca9 100644 --- a/connexion/security.py +++ b/connexion/security.py @@ -67,6 +67,7 @@ class AbstractSecurityHandler: required_scopes_kw = "required_scopes" + request_kw = "request" client = None security_definition_key: str """The key which contains the value for the function name to resolve.""" @@ -106,12 +107,12 @@ def _get_function( return default def _generic_check(self, func, exception_msg): - need_to_add_required_scopes = self._need_to_add_scopes(func) - async def wrapper(request, *args, required_scopes=None): kwargs = {} - if need_to_add_required_scopes: + if self._accepts_kwarg(func, self.required_scopes_kw): kwargs[self.required_scopes_kw] = required_scopes + if self._accepts_kwarg(func, self.request_kw): + kwargs[self.request_kw] = request token_info = func(*args, **kwargs) while asyncio.iscoroutine(token_info): token_info = await token_info @@ -140,10 +141,11 @@ def get_auth_header_value(request): raise OAuthProblem(detail="Invalid authorization header") return auth_type.lower(), value - def _need_to_add_scopes(self, func): + @staticmethod + def _accepts_kwarg(func: t.Callable, keyword: str) -> bool: + """Check if the function accepts the provided keyword argument.""" arguments, has_kwargs = inspect_function_arguments(func) - need_required_scopes = has_kwargs or self.required_scopes_kw in arguments - return need_required_scopes + return has_kwargs or keyword in arguments def _resolve_func(self, security_scheme): """ diff --git a/connexion/utils.py b/connexion/utils.py index 259e45753..5458ca85b 100644 --- a/connexion/utils.py +++ b/connexion/utils.py @@ -512,3 +512,35 @@ def sort_apis_by_basepath(apis: t.List["API"]) -> t.List["API"]: :return: List of APIs sorted by basepath """ return sort_routes(apis, key=lambda api: api.base_path or "/") + + +def build_example_from_schema(schema): + if "example" in schema: + return schema["example"] + + if "properties" in schema: + # Recurse if schema is an object + return { + key: build_example_from_schema(value) + for (key, value) in schema["properties"].items() + } + + if "items" in schema: + # Recurse if schema is an array + min_item_count = schema.get("minItems", 0) + max_item_count = schema.get("maxItems") + + if max_item_count is None or max_item_count >= min_item_count + 1: + item_count = min_item_count + 1 + else: + item_count = min_item_count + + return [build_example_from_schema(schema["items"]) for n in range(item_count)] + + try: + from jsf import JSF + except ImportError: + return None + + faker = JSF(schema) + return faker.generate() diff --git a/docs/cli.rst b/docs/cli.rst index bd77391b4..c91b89de4 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -47,7 +47,10 @@ Running a mock server --------------------- You can run a simple server which returns example responses on every request. -The example responses must be defined in the ``examples`` response property of the OpenAPI specification. + +The example responses can be defined in the ``examples`` response property of +the OpenAPI specification. If no examples are specified, and you have installed connexion with the `mock` extra (`pip install connexion[mock]`), an example is generated based on the provided schema. + Your API specification file is not required to have any ``operationId``. .. code-block:: bash diff --git a/docs/lifespan.rst b/docs/lifespan.rst index 0de70909a..c53341cfb 100644 --- a/docs/lifespan.rst +++ b/docs/lifespan.rst @@ -18,7 +18,7 @@ instance. from connexion import AsyncApp, ConnexionMiddleware, request @contextlib.asynccontextmanager - def lifespan_handler(app: ConnexionMiddleware) -> typing.AsyncIterator: + async def lifespan_handler(app: ConnexionMiddleware) -> typing.AsyncIterator: """Called at startup and shutdown, can yield state which will be available on the request.""" client = Client() @@ -44,7 +44,7 @@ instance. from connexion import FlaskApp, ConnexionMiddleware, request @contextlib.asynccontextmanager - def lifespan_handler(app: ConnexionMiddleware) -> typing.AsyncIterator: + async def lifespan_handler(app: ConnexionMiddleware) -> typing.AsyncIterator: """Called at startup and shutdown, can yield state which will be available on the request.""" client = Client() @@ -71,7 +71,7 @@ instance. from connexion import ConnexionMiddleware, request @contextlib.asynccontextmanager - def lifespan_handler(app: ConnexionMiddleware) -> typing.AsyncIterator: + async def lifespan_handler(app: ConnexionMiddleware) -> typing.AsyncIterator: """Called at startup and shutdown, can yield state which will be available on the request.""" client = Client() @@ -106,4 +106,4 @@ context manager. For more information, please refer to the `Starlette documentation`_. -.. _Starlette documentation: https://www.starlette.io/lifespan/ \ No newline at end of file +.. _Starlette documentation: https://www.starlette.io/lifespan/ diff --git a/docs/quickstart.rst b/docs/quickstart.rst index d025269d4..31c4808f6 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -141,6 +141,7 @@ register an API defined by an OpenAPI (or Swagger) specification. operationId: run.post_greeting responses: 200: + description: "Greeting response" content: text/plain: schema: diff --git a/docs/response.rst b/docs/response.rst index 9d675fb66..42cf1e890 100644 --- a/docs/response.rst +++ b/docs/response.rst @@ -93,7 +93,7 @@ Response Serialization def endpoint(): data = "success" status_code = 200 - headers = {"Content-Type": "text/plain} + headers = {"Content-Type": "text/plain"} return data, status_code, headers Data diff --git a/docs/security.rst b/docs/security.rst index fac723a1d..df45c1007 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -70,6 +70,7 @@ The function should accept the following arguments: - username - password - required_scopes (optional) +- request (optional) You can find a `minimal Basic Auth example application`_ in Connexion's "examples" folder. @@ -85,6 +86,7 @@ The function should accept the following arguments: - token - required_scopes (optional) +- request (optional) You can find a `minimal Bearer example application`_ in Connexion's "examples" folder. @@ -100,6 +102,7 @@ The function should accept the following arguments: - apikey - required_scopes (optional) +- request (optional) You can find a `minimal API Key example application`_ in Connexion's "examples" folder. @@ -115,6 +118,7 @@ The function should accept the following arguments: - token - required_scopes (optional) +- request (optional) As alternative to an ``x-tokenInfoFunc`` definition, you can set an ``x-tokenInfoUrl`` definition or ``TOKENINFO_URL`` environment variable, and connexion will call the url instead of a local @@ -132,6 +136,7 @@ The function should accept the following arguments: - required_scopes - token_scopes +- request (optional) and return a boolean indicating if the validation was successful. diff --git a/pyproject.toml b/pyproject.toml index 260569d9a..019796203 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,24 +48,26 @@ python = '^3.8' asgiref = ">= 3.4" httpx = ">= 0.23" inflection = ">= 0.3.1" -jsonschema = ">= 4.0.1" +jsonschema = ">=4.17.3" Jinja2 = ">= 3.0.0" python-multipart = ">= 0.0.5" PyYAML = ">= 5.1" requests = ">= 2.27" starlette = ">= 0.35" -typing-extensions = ">= 4" +typing-extensions = ">= 4.6.1" werkzeug = ">= 2.2.1" a2wsgi = { version = ">= 1.7", optional = true } flask = { version = ">= 2.2", extras = ["async"], optional = true } swagger-ui-bundle = { version = ">= 1.1.0", optional = true } uvicorn = { version = ">= 0.17.6", extras = ["standard"], optional = true } +jsf = { version = ">=0.10.0", optional = true } [tool.poetry.extras] flask = ["a2wsgi", "flask"] swagger-ui = ["swagger-ui-bundle"] uvicorn = ["uvicorn"] +mock = ["jsf"] [tool.poetry.group.tests.dependencies] pre-commit = "~2.21.0" @@ -106,4 +108,4 @@ exclude_lines = [ [[tool.mypy.overrides]] module = "referencing.jsonschema.*" -follow_imports = "skip" \ No newline at end of file +follow_imports = "skip" diff --git a/tests/decorators/test_security.py b/tests/decorators/test_security.py index abb88eb01..2e1a99e7e 100644 --- a/tests/decorators/test_security.py +++ b/tests/decorators/test_security.py @@ -328,3 +328,52 @@ def test_raise_most_specific(errors, most_specific): security_handler_factory = SecurityHandlerFactory() with pytest.raises(most_specific): security_handler_factory._raise_most_specific(errors) + + +async def test_optional_kwargs_injected(): + """Test that optional keyword arguments 'required_scopes' and 'request' are injected when + defined as arguments in the user security function. This test uses the ApiKeySecurityHandler, + but the tested behavior is generic across handlers.""" + security_handler_factory = ApiKeySecurityHandler() + + request = ConnexionRequest( + scope={"type": "http", "headers": [[b"x-auth", b"foobar"]]} + ) + + def apikey_info_no_kwargs(key): + """Will fail if additional keywords are injected.""" + return {"sub": "no_kwargs"} + + wrapped_func_no_kwargs = security_handler_factory._get_verify_func( + apikey_info_no_kwargs, "header", "X-Auth" + ) + assert await wrapped_func_no_kwargs(request) == {"sub": "no_kwargs"} + + def apikey_info_request(key, request): + """Will fail if request is not injected.""" + return {"sub": "request"} + + wrapped_func_request = security_handler_factory._get_verify_func( + apikey_info_request, "header", "X-Auth" + ) + assert await wrapped_func_request(request) == {"sub": "request"} + + def apikey_info_scopes(key, required_scopes): + """Will fail if required_scopes is not injected.""" + return {"sub": "scopes"} + + wrapped_func_scopes = security_handler_factory._get_verify_func( + apikey_info_scopes, "header", "X-Auth" + ) + assert await wrapped_func_scopes(request) == {"sub": "scopes"} + + def apikey_info_kwargs(key, **kwargs): + """Will fail if request and required_scopes are not injected.""" + assert "request" in kwargs + assert "required_scopes" in kwargs + return {"sub": "kwargs"} + + wrapped_func_kwargs = security_handler_factory._get_verify_func( + apikey_info_kwargs, "header", "X-Auth" + ) + assert await wrapped_func_kwargs(request) == {"sub": "kwargs"} diff --git a/tests/test_mock.py b/tests/test_mock.py index 1307f9298..2b0492925 100644 --- a/tests/test_mock.py +++ b/tests/test_mock.py @@ -224,7 +224,8 @@ def test_mock_resolver_no_example_nested_in_object(): response, status_code = resolver.mock_operation(operation) assert status_code == 200 - assert response == "No example response was defined." + assert isinstance(response, dict) + assert isinstance(response["foo"], str) def test_mock_resolver_no_example_nested_in_list_openapi(): @@ -256,7 +257,8 @@ def test_mock_resolver_no_example_nested_in_list_openapi(): response, status_code = resolver.mock_operation(operation) assert status_code == 202 - assert response == "No example response was defined." + assert isinstance(response, list) + assert all(isinstance(c, str) for c in response) def test_mock_resolver_no_examples(): @@ -278,7 +280,7 @@ def test_mock_resolver_no_examples(): response, status_code = resolver.mock_operation(operation) assert status_code == 418 - assert response == "No example response was defined." + assert response == "No example response or response schema defined." def test_mock_resolver_notimplemented(): @@ -315,4 +317,7 @@ def test_mock_resolver_notimplemented(): ) # check if it is using the mock function - assert operation._resolution.function() == ("No example response was defined.", 418) + assert operation._resolution.function() == ( + "No example response or response schema defined.", + 418, + ) diff --git a/tests/test_mock2.py b/tests/test_mock2.py new file mode 100644 index 000000000..1cfbc8577 --- /dev/null +++ b/tests/test_mock2.py @@ -0,0 +1,162 @@ +from datetime import datetime +from re import fullmatch + +from connexion.utils import build_example_from_schema + + +def test_build_example_from_schema_string(): + schema = { + "type": "string", + } + example = build_example_from_schema(schema) + assert isinstance(example, str) + + +def test_build_example_from_schema_integer(): + schema = { + "type": "integer", + } + example = build_example_from_schema(schema) + assert isinstance(example, int) + + +def test_build_example_from_schema_number(): + schema = { + "type": "number", + } + example = build_example_from_schema(schema) + assert isinstance(example, float) + + +def test_build_example_from_schema_boolean(): + schema = { + "type": "boolean", + } + example = build_example_from_schema(schema) + assert isinstance(example, bool) + + +def test_build_example_from_schema_integer_minimum(): + schema = { + "type": "integer", + "minimum": 4, + } + example = build_example_from_schema(schema) + assert example >= schema["minimum"] and isinstance(example, int) + + +def test_build_example_from_schema_integer_maximum(): + schema = { + "type": "integer", + "maximum": 17, + } + example = build_example_from_schema(schema) + assert example <= schema["maximum"] and isinstance(example, int) + + +def test_build_example_from_schema_integer_exclusive_minimum(): + schema = { + "type": "integer", + "minimum": 4, + "exclusiveMinimum": True, + } + example = build_example_from_schema(schema) + assert example > schema["minimum"] and isinstance(example, int) + + +def test_build_example_from_schema_integer_exclusive_maximum(): + schema = { + "type": "integer", + "maximum": 17, + "exclusiveMaximum": True, + } + example = build_example_from_schema(schema) + assert example < schema["maximum"] and isinstance(example, int) + + +def test_build_example_from_schema_string_regular_expression(): + pattern = r"^\d{3}-\d{2}-\d{4}$" + schema = { + "type": "string", + "pattern": pattern, + } + example = build_example_from_schema(schema) + assert fullmatch(pattern, example) != None and isinstance(example, str) + + +def test_build_example_from_schema_string_maximum(): + schema = { + "type": "string", + "maxLength": 20, + } + example = build_example_from_schema(schema) + assert isinstance(example, str) and len(example) <= schema["maxLength"] + + +def test_build_example_from_schema_string_minimum(): + schema = { + "type": "string", + "minLength": 20, + } + example = build_example_from_schema(schema) + assert isinstance(example, str) and len(example) >= schema["minLength"] + + +def test_build_example_from_schema_enum(): + schema = {"type": "string", "enum": ["asc", "desc"]} + example = build_example_from_schema(schema) + assert isinstance(example, str) + assert example == "asc" or example == "desc" + + +def test_build_example_from_complex_schema(): + schema = { + "type": "object", + "properties": { + "datetimeField": {"type": "string", "format": "date-time"}, + "integerField": { + "type": "integer", + "minimum": 2, + "maximum": 5, + "exclusiveMinimum": True, + "multipleOf": 2, + }, + "arrayOfNumbersField": { + "type": "array", + "items": { + "type": "number", + "format": "float", + "minimum": 0.1, + "maximum": 0.9, + "multipleOf": 0.1, + }, + "minItems": 3, + "maxItems": 5, + }, + "objectField": { + "type": "object", + "properties": { + "nestedBoolean": {"type": "boolean"}, + "stringWithExample": { + "type": "string", + "example": "example-string", + }, + }, + }, + }, + } + example = build_example_from_schema(schema) + + # Check that ValueError is not raised on invalid datetime. + datetime.fromisoformat(example["datetimeField"]) + assert example["integerField"] == 4 + + assert isinstance(example["arrayOfNumbersField"], list) + assert 3 <= len(example["arrayOfNumbersField"]) <= 5 + assert all(0.1 <= num <= 0.9 for num in example["arrayOfNumbersField"]) + + example_boolean = example["objectField"]["nestedBoolean"] + assert example_boolean is True or example_boolean is False + + # Check that if an example is provided then it is used directly. + assert example["objectField"]["stringWithExample"] == "example-string" diff --git a/tests/test_mock3.py b/tests/test_mock3.py index 1db6055ff..4dc405767 100644 --- a/tests/test_mock3.py +++ b/tests/test_mock3.py @@ -103,7 +103,7 @@ def test_mock_resolver_no_examples(): response, status_code = resolver.mock_operation(operation) assert status_code == 418 - assert response == "No example response was defined." + assert response == "No example response or response schema defined." def test_mock_resolver_notimplemented(): @@ -133,4 +133,7 @@ def test_mock_resolver_notimplemented(): resolver=resolver, ) # check if it is using the mock function - assert operation._resolution.function() == ("No example response was defined.", 418) + assert operation._resolution.function() == ( + "No example response or response schema defined.", + 418, + )