From bd956aa18a7617f843aa44a6ae0569d4c54cfbf8 Mon Sep 17 00:00:00 2001 From: Christopher Lott <10234212+chrisinmtown@users.noreply.github.com> Date: Sat, 4 Jan 2025 06:57:34 -0500 Subject: [PATCH] Add example of the Starlette test client on a simple Connexion REST app The example Connexion app processes JSON requests and responses as specified with OpenAPI v2 (aka Swagger) or OpenAPI v3 file format. JSON responses are validated against the spec. An error handler catches exceptions raised while processing a request. Tests are run by `tox` which also reports code coverage. --- examples/testclient/README.rst | 45 +++++++++++++++++++ examples/testclient/hello/__init__.py | 47 ++++++++++++++++++++ examples/testclient/hello/app.py | 30 +++++++++++++ examples/testclient/hello/spec/openapi.yaml | 48 +++++++++++++++++++++ examples/testclient/hello/spec/swagger.yaml | 44 +++++++++++++++++++ examples/testclient/requirements.txt | 1 + examples/testclient/tests/__init__.py | 2 + examples/testclient/tests/conftest.py | 17 ++++++++ examples/testclient/tests/test_app.py | 47 ++++++++++++++++++++ examples/testclient/tests/test_init.py | 18 ++++++++ examples/testclient/tox.ini | 19 ++++++++ 11 files changed, 318 insertions(+) create mode 100644 examples/testclient/README.rst create mode 100644 examples/testclient/hello/__init__.py create mode 100644 examples/testclient/hello/app.py create mode 100644 examples/testclient/hello/spec/openapi.yaml create mode 100644 examples/testclient/hello/spec/swagger.yaml create mode 100644 examples/testclient/requirements.txt create mode 100644 examples/testclient/tests/__init__.py create mode 100644 examples/testclient/tests/conftest.py create mode 100644 examples/testclient/tests/test_app.py create mode 100644 examples/testclient/tests/test_init.py create mode 100644 examples/testclient/tox.ini diff --git a/examples/testclient/README.rst b/examples/testclient/README.rst new file mode 100644 index 000000000..1e513bd29 --- /dev/null +++ b/examples/testclient/README.rst @@ -0,0 +1,45 @@ +=================== +Test Client Example +=================== + +This directory offers an example of using the Starlette test client +to test a Connexion app. The app processes JSON requests and responses +as specified with OpenAPI v2 (aka Swagger) or OpenAPI v3 file format. +In addition, the responses are validated against the spec, and an error +handler catches exceptions raised while processing a request. The tests +are run by `tox` which also reports code coverage. + +Preparing +--------- + +Create a new virtual environment and install the required libraries +with these commands: + +.. code-block:: bash + + $ python -m venv my-venv + $ source my-venv/bin/activate + $ pip install 'connexion[flask,swagger-ui,uvicorn]>=3.1.0' tox + +Testing +------- + +Run the test suite and generate the coverage report with this command: + +.. code-block:: bash + + $ tox + +Running +------- + +Launch the connexion server with this command: + +.. code-block:: bash + + $ python -m hello.app + +Now open your browser and view the Swagger UI for these specification files: + +* http://localhost:8080/openapi/ui/ for the OpenAPI 3 spec +* http://localhost:8080/swagger/ui/ for the Swagger 2 spec diff --git a/examples/testclient/hello/__init__.py b/examples/testclient/hello/__init__.py new file mode 100644 index 000000000..9766b0351 --- /dev/null +++ b/examples/testclient/hello/__init__.py @@ -0,0 +1,47 @@ +import logging + +from connexion import FlaskApp +from connexion.lifecycle import ConnexionRequest, ConnexionResponse +from connexion.problem import problem + +logger = logging.getLogger(__name__) + + +async def handle_error(request: ConnexionRequest, ex: Exception) -> ConnexionResponse: + """ + Report an error that happened while processing a request. + See: https://connexion.readthedocs.io/en/latest/exceptions.html + + This function is defined as `async` so it can be called by the + Connexion asynchronous middleware framework without a wrapper. + If a plain function is provided, the framework calls the function + from a threadpool and the exception stack trace is not available. + + :param request: Request that failed + :parm ex: Exception that was raised + :return: ConnexionResponse with RFC7087 problem details + """ + # log the request URL, exception and stack trace + logger.exception("Request to %s caused exception", request.url) + return problem(title="Error", status=500, detail=repr(ex)) + + +def create_app() -> FlaskApp: + """ + Create the Connexion FlaskApp, which wraps a Flask app. + + :return Newly created FlaskApp + """ + app = FlaskApp(__name__, specification_dir="spec/") + # hook the functions to the OpenAPI spec + title = {"title": "Hello World Plus Example"} + app.add_api("openapi.yaml", arguments=title, validate_responses=True) + app.add_api("swagger.yaml", arguments=title, validate_responses=True) + # hook an async function that is invoked on any exception + app.add_error_handler(Exception, handle_error) + # return the fully initialized FlaskApp + return app + + +# create and publish for import by other modules +conn_app = create_app() diff --git a/examples/testclient/hello/app.py b/examples/testclient/hello/app.py new file mode 100644 index 000000000..adf9f14d6 --- /dev/null +++ b/examples/testclient/hello/app.py @@ -0,0 +1,30 @@ +import logging + +from . import conn_app + +# reuse the configured logger +logger = logging.getLogger("uvicorn.error") + + +def post_greeting(name: str, body: dict) -> tuple: + logger.info( + "%s: name len %d, body items %d", post_greeting.__name__, len(name), len(body) + ) + # the body is optional + message = body.get("message", None) + if "crash" == message: + raise ValueError(f"Raise exception for {name}") + if "invalid" == message: + return {"bogus": "response"} + return {"greeting": f"Hello {name}"}, 200 + + +def main() -> None: + # ensure logging is configured + logging.basicConfig(level=logging.DEBUG) + # launch the app using the dev server + conn_app.run("hello:conn_app", port=8080) + + +if __name__ == "__main__": + main() diff --git a/examples/testclient/hello/spec/openapi.yaml b/examples/testclient/hello/spec/openapi.yaml new file mode 100644 index 000000000..67fa319bc --- /dev/null +++ b/examples/testclient/hello/spec/openapi.yaml @@ -0,0 +1,48 @@ +openapi: "3.0.0" + +info: + title: Hello World + version: "1.0" + +servers: + - url: /openapi + +paths: + /greeting/{name}: + post: + summary: Generate greeting + description: Generates a greeting message. + operationId: hello.app.post_greeting + parameters: + - name: name + in: path + description: Name of the person to greet. + required: true + schema: + type: string + example: "dave" + requestBody: + description: > + Optional body with a message. + Send message "crash" or "invalid" to simulate an error. + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: "hi" + responses: + '200': + description: greeting response + content: + application/json: + schema: + type: object + properties: + greeting: + type: string + example: "Hello John" + required: + - greeting diff --git a/examples/testclient/hello/spec/swagger.yaml b/examples/testclient/hello/spec/swagger.yaml new file mode 100644 index 000000000..6a96f4936 --- /dev/null +++ b/examples/testclient/hello/spec/swagger.yaml @@ -0,0 +1,44 @@ +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /swagger + +paths: + /greeting/{name}: + post: + summary: Generate greeting + operationId: hello.app.post_greeting + parameters: + - name: name + in: path + description: Name of the person to greet. + required: true + type: string + - name: body + in: body + description: > + Optional body with a message. + Send message "crash" or "invalid" to simulate an error. + schema: + type: object + properties: + message: + type: string + example: "hi" + produces: + - application/json + responses: + '200': + description: greeting response + schema: + type: object + properties: + greeting: + type: string + required: + - greeting + example: + greeting: "Hello John" diff --git a/examples/testclient/requirements.txt b/examples/testclient/requirements.txt new file mode 100644 index 000000000..1037d9976 --- /dev/null +++ b/examples/testclient/requirements.txt @@ -0,0 +1 @@ +connexion[flask,swagger-ui,uvicorn] diff --git a/examples/testclient/tests/__init__.py b/examples/testclient/tests/__init__.py new file mode 100644 index 000000000..e32e0bec3 --- /dev/null +++ b/examples/testclient/tests/__init__.py @@ -0,0 +1,2 @@ +# empty __init__.py so that pytest can add correct path to coverage report +# https://github.com/pytest-dev/pytest-cov/issues/98#issuecomment-451344057 diff --git a/examples/testclient/tests/conftest.py b/examples/testclient/tests/conftest.py new file mode 100644 index 000000000..cdc87d84b --- /dev/null +++ b/examples/testclient/tests/conftest.py @@ -0,0 +1,17 @@ +""" +fixtures available for injection to tests by pytest +""" +import pytest +from starlette.testclient import TestClient +from hello.app import conn_app + + +@pytest.fixture +def client(): + """ + Create a Connexion test_client from the Connexion app. + + https://connexion.readthedocs.io/en/stable/testing.html + """ + client: TestClient = conn_app.test_client() + yield client diff --git a/examples/testclient/tests/test_app.py b/examples/testclient/tests/test_app.py new file mode 100644 index 000000000..0ede34415 --- /dev/null +++ b/examples/testclient/tests/test_app.py @@ -0,0 +1,47 @@ +from httpx import Response +from pytest_mock import MockerFixture +from starlette.testclient import TestClient +from hello import app + +greeting = "greeting" +prefixes = ["openapi", "swagger"] + + +def test_greeting_success(client: TestClient): + name = "dave" + for prefix in prefixes: + # a body is required in the POST + res: Response = client.post( + f"/{prefix}/{greeting}/{name}", json={"message": "hi"} + ) + assert res.status_code == 200 + assert name in res.json()[greeting] + + +def test_greeting_exception(client: TestClient): + name = "dave" + for prefix in prefixes: + # a body is required in the POST + res: Response = client.post( + f"/{prefix}/{greeting}/{name}", json={"message": "crash"} + ) + assert res.status_code == 500 + assert name in res.json()["detail"] + + +def test_greeting_invalid(client: TestClient): + name = "dave" + for prefix in prefixes: + # a body is required in the POST + res: Response = client.post( + f"/{prefix}/{greeting}/{name}", json={"message": "invalid"} + ) + assert res.status_code == 500 + assert "Response body does not conform" in res.json()["detail"] + + +def test_main(mocker: MockerFixture): + # patch the run-app function to do nothing + mock_run = mocker.patch("hello.app.conn_app.run") + app.main() + mock_run.assert_called() diff --git a/examples/testclient/tests/test_init.py b/examples/testclient/tests/test_init.py new file mode 100644 index 000000000..8c42501f9 --- /dev/null +++ b/examples/testclient/tests/test_init.py @@ -0,0 +1,18 @@ +from connexion.lifecycle import ConnexionResponse +import json +import pytest +from unittest.mock import Mock +from hello import handle_error + + +@pytest.mark.asyncio +async def test_handle_error(): + # Mock the ConnexionRequest object + mock_req = Mock() + mock_req.url = "http://some/url" + # call the function + conn_resp: ConnexionResponse = await handle_error(mock_req, ValueError("Value")) + assert 500 == conn_resp.status_code + # check structure of response + resp_dict = json.loads(conn_resp.body) + assert "Error" == resp_dict["title"] diff --git a/examples/testclient/tox.ini b/examples/testclient/tox.ini new file mode 100644 index 000000000..a10577c4f --- /dev/null +++ b/examples/testclient/tox.ini @@ -0,0 +1,19 @@ +[tox] +envlist = code +minversion = 2.0 + +[pytest] +testpaths = tests + +[testenv:code] +basepython = python3 +deps= + pytest + pytest-asyncio + pytest-cov + pytest-mock + -r requirements.txt +commands = + # posargs allows running just a single test like this: + # tox -- tests/test_foo.py::test_bar + pytest --cov hello --cov-report term-missing --cov-fail-under=70 {posargs}