Skip to content

Commit

Permalink
Rework OpenAPI client configuration so in tests so the application ca…
Browse files Browse the repository at this point in the history
…n be changed before it is started.

This accomodates a change in Connexion the disallows changes in the test
application after the test client has been instantiated.

Related #33
  • Loading branch information
aholmes committed Jan 19, 2024
1 parent 64e1c1e commit 52999b1
Show file tree
Hide file tree
Showing 4 changed files with 107 additions and 46 deletions.
4 changes: 2 additions & 2 deletions src/web/test/unit/application/test_create_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from pydantic import BaseModel
from pytest_mock import MockerFixture

from ..create_app import CreateApp, FlaskClientConfigurable, TFlaskClient
from ..create_app import ClientConfigurable, CreateApp, TFlaskClient


class TestCreateApp(CreateApp):
Expand Down Expand Up @@ -274,7 +274,7 @@ def test__configure_openapi__creates_flask_app_using_config(
connexion_mock.assert_called_with(app_name, specification_dir=spec_path)

def test__create_app__requires_flask_config(
self, flask_client_configurable: FlaskClientConfigurable[TFlaskClient]
self, flask_client_configurable: ClientConfigurable[TFlaskClient]
):
with pytest.raises(
Exception,
Expand Down
96 changes: 75 additions & 21 deletions src/web/test/unit/create_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@
from contextlib import ExitStack
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, Callable, Generator, Generic, Protocol, TypeVar, cast
from typing import (
TYPE_CHECKING,
Any,
Callable,
Generator,
Generic,
Protocol,
TypeVar,
cast,
)

import json_logging
import pytest
Expand All @@ -21,7 +30,6 @@
)
from BL_Python.web.encryption import encrypt_flask_cookie
from connexion import FlaskApp
from connexion.apps.abstract import TestClient as _TestClient
from flask import Flask, Request, Response
from flask.ctx import RequestContext
from flask.sessions import SecureCookieSession
Expand All @@ -30,10 +38,15 @@
from mock import MagicMock
from pytest_mock import MockerFixture


# In BL_Python.web `app` is always a Flask application
class TestClient(_TestClient):
app: Flask
# fmt: off
if TYPE_CHECKING:
from connexion.apps.abstract import TestClient as _TestClient # isort: skip
# In BL_Python.web `app` is always a Flask application
class TestClient(_TestClient):
app: Flask
else:
from connexion.apps.abstract import TestClient
# fmt: on


TFlaskClient = FlaskClient | TestClient
Expand All @@ -49,10 +62,9 @@ class FlaskClientInjector(Generic[T_flask_client]):

client: T_flask_client
injector: FlaskInjector
# connexion_app: FlaskApp | None = None


class FlaskAppGetter(Protocol, Generic[T_flask_app]):
class AppGetter(Protocol, Generic[T_flask_app]):
"""
A callable that instantiates a Flask application
and returns the application with its IoC container.
Expand All @@ -62,7 +74,7 @@ def __call__(self) -> FlaskAppInjector[T_flask_app]:
...


class FlaskClientConfigurable(Protocol, Generic[T_flask_client]):
class ClientConfigurable(Protocol, Generic[T_flask_client]):
"""
Get a Flask test client using the specified application configuration.
Expand All @@ -72,14 +84,37 @@ class FlaskClientConfigurable(Protocol, Generic[T_flask_client]):
Returns
------
`FlaskClientInjector`
`FlaskClientInjector[T_flask_client]`
"""

def __call__(self, config: Config) -> FlaskClientInjector[T_flask_client]:
...


class FlaskRequestConfigurable(Protocol):
class OpenAPIClientConfigurable(ClientConfigurable[TestClient], Protocol):
"""
Get a Flask test client using the specified application configuration.
Args
------
config: `Config` The custom application configuration used to instantiate the Flask app.
app_init_hook: `Callable[[FlaskAppInjector[FlaskApp]], None] | None = None` A method that is
called after the application is created, but before it is started.
Returns
------
`FlaskClientInjector[TestClient]`
"""

def __call__( # pyright: ignore[reportImplicitOverride]
self,
config: Config,
app_init_hook: Callable[[FlaskAppInjector[FlaskApp]], None] | None = None,
) -> FlaskClientInjector[TestClient]:
...


class RequestConfigurable(Protocol):
"""
Get a Flask request context, creating a Flask test client
that uses the specified application configuration and,
Expand Down Expand Up @@ -210,7 +245,7 @@ def _get_openapi_app(
return next(self.__get_openapi_app(openapi_config, mocker))

def _flask_client(
self, flask_app_getter: FlaskAppGetter[Flask]
self, flask_app_getter: AppGetter[Flask]
) -> Generator[FlaskClientInjector[FlaskClient], Any, None]:
with ExitStack() as stack:
result = flask_app_getter()
Expand All @@ -221,7 +256,7 @@ def _flask_client(
client, FlaskClient
): # pyright: ignore[reportUnnecessaryIsInstance]
raise Exception(
f"""This fixture created a `{type(client)}` test client, but is only meant for `{type(FlaskClient)}`.
f"""This fixture created a `{type(client)}` test client, but is only meant for `{FlaskClient}`.
Ensure either that [openapi] is not set in the [flask] config, or use the `openapi_client` fixture."""
)

Expand All @@ -247,18 +282,16 @@ def _flask_client(
yield FlaskClientInjector(client, result.injector) # , connexion_app)

def _openapi_client(
self, flask_app_getter: FlaskAppGetter[FlaskApp]
self, flask_app_getter: AppGetter[FlaskApp]
) -> Generator[FlaskClientInjector[TestClient], Any, None]:
with ExitStack() as stack:
result = flask_app_getter()
app = result.app
client = stack.enter_context(app.test_client())

if not isinstance(
client, TestClient
): # pyright: ignore[reportUnnecessaryIsInstance]
if not isinstance(client, TestClient):
raise Exception(
f"""This fixture created a `{type(client)}` test client, but is only meant for `{type(TestClient)}`.
f"""This fixture created a `{type(client)}` test client, but is only meant for `{TestClient}`.
Ensure either that [openapi] is set in the [flask] config, or use the `flask_client` fixture."""
)
# client.cookies.set(
Expand All @@ -284,10 +317,16 @@ def flask_client(
) -> FlaskClientInjector[FlaskClient]:
return next(self._flask_client(lambda: _get_basic_flask_app))

@pytest.fixture()
def openapi_client(
self, _get_basic_flask_app: FlaskAppInjector[FlaskApp]
) -> FlaskClientInjector[TestClient]:
return next(self._openapi_client(lambda: _get_basic_flask_app))

@pytest.fixture()
def flask_client_configurable(
self, mocker: MockerFixture
) -> FlaskClientConfigurable[FlaskClient]:
) -> ClientConfigurable[FlaskClient]:
def _flask_client_getter(config: Config):
return next(
self._flask_client(
Expand All @@ -297,6 +336,21 @@ def _flask_client_getter(config: Config):

return _flask_client_getter

@pytest.fixture()
def openapi_client_configurable(
self, mocker: MockerFixture
) -> OpenAPIClientConfigurable:
def _openapi_client_getter(
config: Config,
app_init_hook: Callable[[FlaskAppInjector[FlaskApp]], None] | None = None,
):
application_result = next(self.__get_openapi_app(config, mocker))
if app_init_hook is not None:
app_init_hook(application_result)
return next(self._openapi_client(lambda: application_result))

return _openapi_client_getter

def _flask_request(
self,
flask_client: FlaskClient,
Expand All @@ -317,8 +371,8 @@ def flask_request(
@pytest.fixture()
def flask_request_configurable(
self,
flask_client_configurable: FlaskClientConfigurable[FlaskClient],
) -> FlaskRequestConfigurable:
flask_client_configurable: ClientConfigurable[FlaskClient],
) -> RequestConfigurable:
def _flask_request_getter(
config: Config, request_context_args: dict[Any, Any] | None = None
):
Expand Down
6 changes: 3 additions & 3 deletions src/web/test/unit/middleware/test_api_response_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from pytest import LogCaptureFixture
from pytest_mock import MockerFixture

from ..create_app import CreateApp, FlaskClientConfigurable, FlaskClientInjector
from ..create_app import ClientConfigurable, CreateApp, FlaskClientInjector


class TestApiResponseHandlers(CreateApp):
Expand All @@ -34,7 +34,7 @@ def test__register_api_response_handlers__binds_flask_before_request(

def test__wrap_all_api_responses__sets_CSP_header(
self,
flask_client_configurable: FlaskClientConfigurable[FlaskClient],
flask_client_configurable: ClientConfigurable[FlaskClient],
basic_config: Config,
):
csp_value = "default-src 'self' cdn.example.com;"
Expand Down Expand Up @@ -71,7 +71,7 @@ def test__wrap_all_api_responses__sets_CORS_headers(
header: str,
value: str,
config_attribute_name: str,
flask_client_configurable: FlaskClientConfigurable[FlaskClient],
flask_client_configurable: ClientConfigurable[FlaskClient],
basic_config: Config,
):
setattr(basic_config.web.security.cors, config_attribute_name, value)
Expand Down
Loading

0 comments on commit 52999b1

Please sign in to comment.