Skip to content

Commit

Permalink
Merge branch 'main' into sort-routes-middleware
Browse files Browse the repository at this point in the history
  • Loading branch information
RobbeSneyders authored May 27, 2024
2 parents d0a4d29 + 160a1c0 commit 93c4a1e
Show file tree
Hide file tree
Showing 17 changed files with 303 additions and 74 deletions.
4 changes: 2 additions & 2 deletions .readthedocs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
```

<p align="right">(<a href="#top">back to top</a>)</p>
Expand Down Expand Up @@ -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
[Architecture]: https://github.com/spec-first/connexion/blob/main/docs/images/architecture.png
7 changes: 6 additions & 1 deletion connexion/mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
28 changes: 4 additions & 24 deletions connexion/operations/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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 = {}
Expand Down
28 changes: 4 additions & 24 deletions connexion/operations/swagger2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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")
Expand Down
14 changes: 8 additions & 6 deletions connexion/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down
32 changes: 32 additions & 0 deletions connexion/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
5 changes: 4 additions & 1 deletion docs/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions docs/lifespan.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -106,4 +106,4 @@ context manager.
For more information, please refer to the `Starlette documentation`_.

.. _Starlette documentation: https://www.starlette.io/lifespan/
.. _Starlette documentation: https://www.starlette.io/lifespan/
1 change: 1 addition & 0 deletions docs/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/response.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/security.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand All @@ -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.

Expand All @@ -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
Expand All @@ -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.

Expand Down
8 changes: 5 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -106,4 +108,4 @@ exclude_lines = [

[[tool.mypy.overrides]]
module = "referencing.jsonschema.*"
follow_imports = "skip"
follow_imports = "skip"
49 changes: 49 additions & 0 deletions tests/decorators/test_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Loading

0 comments on commit 93c4a1e

Please sign in to comment.