Skip to content

Commit

Permalink
Add example of the Starlette test client on a simple Connexion REST app
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
chrisinmtown committed Jan 4, 2025
1 parent 1844a2f commit bd956aa
Show file tree
Hide file tree
Showing 11 changed files with 318 additions and 0 deletions.
45 changes: 45 additions & 0 deletions examples/testclient/README.rst
Original file line number Diff line number Diff line change
@@ -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
47 changes: 47 additions & 0 deletions examples/testclient/hello/__init__.py
Original file line number Diff line number Diff line change
@@ -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()
30 changes: 30 additions & 0 deletions examples/testclient/hello/app.py
Original file line number Diff line number Diff line change
@@ -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()
48 changes: 48 additions & 0 deletions examples/testclient/hello/spec/openapi.yaml
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions examples/testclient/hello/spec/swagger.yaml
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions examples/testclient/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
connexion[flask,swagger-ui,uvicorn]
2 changes: 2 additions & 0 deletions examples/testclient/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions examples/testclient/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
47 changes: 47 additions & 0 deletions examples/testclient/tests/test_app.py
Original file line number Diff line number Diff line change
@@ -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()
18 changes: 18 additions & 0 deletions examples/testclient/tests/test_init.py
Original file line number Diff line number Diff line change
@@ -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"]
19 changes: 19 additions & 0 deletions examples/testclient/tox.ini
Original file line number Diff line number Diff line change
@@ -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}

0 comments on commit bd956aa

Please sign in to comment.