Skip to content

Commit a2e4702

Browse files
committed
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.
1 parent 550ba1a commit a2e4702

File tree

11 files changed

+320
-0
lines changed

11 files changed

+320
-0
lines changed

examples/testclient/README.rst

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
===================
2+
Test Client Example
3+
===================
4+
5+
This directory offers an example of using the Starlette test client
6+
to test a Connexion app. The app processes JSON requests and responses
7+
as specified with OpenAPI v2 (aka Swagger) or OpenAPI v3 file format.
8+
The responses are validated against the spec, and an error handler
9+
catches exceptions raised while processing a request. The tests are
10+
run by `tox` which also reports code coverage.
11+
12+
Preparing
13+
---------
14+
15+
Create a new virtual environment and install the required libraries
16+
with these commands:
17+
18+
.. code-block:: bash
19+
20+
$ python -m venv my-venv
21+
$ source my-venv/bin/activate
22+
$ pip install 'connexion[flask,swagger-ui,uvicorn]>=3.1.0' tox
23+
24+
Testing
25+
-------
26+
27+
Run the test suite and generate the coverage report with this command:
28+
29+
.. code-block:: bash
30+
31+
$ tox
32+
33+
Running
34+
-------
35+
36+
Launch the connexion server with this command:
37+
38+
.. code-block:: bash
39+
40+
$ python -m hello.app
41+
42+
Now open your browser and view the Swagger UI for these specification files:
43+
44+
* http://localhost:8080/openapi/ui/ for the OpenAPI 3 spec
45+
* http://localhost:8080/swagger/ui/ for the Swagger 2 spec

examples/testclient/hello/__init__.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import logging
2+
3+
import connexion
4+
from connexion.lifecycle import ConnexionRequest, ConnexionResponse
5+
from connexion.problem import problem
6+
7+
logger = logging.getLogger(__name__)
8+
9+
10+
async def handle_error(request: ConnexionRequest, ex: Exception) -> ConnexionResponse:
11+
"""
12+
Report an error that happened while processing a request.
13+
See: https://connexion.readthedocs.io/en/latest/exceptions.html
14+
15+
This function is defined as `async` so it can be called by the
16+
Connexion asynchronous middleware framework without a wrapper.
17+
If a plain function is provided, the framework calls the function
18+
from a threadpool and the exception stack trace is not available.
19+
20+
:param request: Request that failed
21+
:parm ex: Exception that was raised
22+
:return: ConnexionResponse with RFC7087 problem details
23+
"""
24+
# log the request URL, exception and stack trace
25+
logger.exception("Request to %s caused exception", request.url)
26+
return problem(title="Error", status=500, detail=repr(ex))
27+
28+
29+
def create_app() -> connexion.FlaskApp:
30+
"""
31+
Create the Connexion FlaskApp, which wraps a Flask app.
32+
33+
:return Newly created FlaskApp
34+
"""
35+
app = connexion.FlaskApp(__name__, specification_dir="spec/")
36+
# hook the functions to the OpenAPI spec
37+
title = {"title": "Hello World Plus Example"}
38+
app.add_api("openapi.yaml", arguments=title, validate_responses=True)
39+
app.add_api("swagger.yaml", arguments=title, validate_responses=True)
40+
# hook an async function that is invoked on any exception
41+
app.add_error_handler(Exception, handle_error)
42+
# return the fully initialized FlaskApp
43+
return app
44+
45+
46+
# create and publish for import by other modules
47+
conn_app = create_app()

examples/testclient/hello/app.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import logging
2+
3+
from . import conn_app
4+
5+
# reuse the configured logger
6+
logger = logging.getLogger("uvicorn.error")
7+
8+
9+
def post_greeting(name: str, body: dict) -> tuple:
10+
logger.info(
11+
"%s: name len %d, body items %d", post_greeting.__name__, len(name), len(body)
12+
)
13+
# the body is optional
14+
message = body.get("message", None)
15+
if "crash" == message:
16+
raise ValueError(f"Raise exception for {name}")
17+
if "invalid" == message:
18+
return {"bogus": "response"}
19+
return {"greeting": f"Hello {name}"}, 200
20+
21+
22+
def main() -> None:
23+
# ensure logging is configured
24+
logging.basicConfig(level=logging.DEBUG)
25+
# launch the app using the dev server
26+
conn_app.run("hello:conn_app", port=8080)
27+
28+
29+
if __name__ == "__main__":
30+
main()
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
openapi: "3.0.0"
2+
3+
info:
4+
title: Hello World
5+
version: "1.0"
6+
7+
servers:
8+
- url: /openapi
9+
10+
paths:
11+
/greeting/{name}:
12+
post:
13+
summary: Generate greeting
14+
description: Generates a greeting message.
15+
operationId: hello.app.post_greeting
16+
parameters:
17+
- name: name
18+
in: path
19+
description: Name of the person to greet.
20+
required: true
21+
schema:
22+
type: string
23+
example: "dave"
24+
requestBody:
25+
description: >
26+
Optional body with a message.
27+
Send message "crash" or "invalid" to simulate an error.
28+
content:
29+
application/json:
30+
schema:
31+
type: object
32+
properties:
33+
message:
34+
type: string
35+
example: "hi"
36+
responses:
37+
'200':
38+
description: greeting response
39+
content:
40+
application/json:
41+
schema:
42+
type: object
43+
properties:
44+
greeting:
45+
type: string
46+
example: "Hello John"
47+
required:
48+
- greeting
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
swagger: "2.0"
2+
3+
info:
4+
title: "{{title}}"
5+
version: "1.0"
6+
7+
basePath: /swagger
8+
9+
paths:
10+
/greeting/{name}:
11+
post:
12+
summary: Generate greeting
13+
operationId: hello.app.post_greeting
14+
parameters:
15+
- name: name
16+
in: path
17+
description: Name of the person to greet.
18+
required: true
19+
type: string
20+
- name: body
21+
in: body
22+
description: >
23+
Optional body with a message.
24+
Send message "crash" or "invalid" to simulate an error.
25+
schema:
26+
type: object
27+
properties:
28+
message:
29+
type: string
30+
example: "hi"
31+
produces:
32+
- application/json
33+
responses:
34+
'200':
35+
description: greeting response
36+
schema:
37+
type: object
38+
properties:
39+
greeting:
40+
type: string
41+
required:
42+
- greeting
43+
example:
44+
greeting: "Hello John"

examples/testclient/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
connexion[flask,swagger-ui,uvicorn]

examples/testclient/tests/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# empty __init__.py so that pytest can add correct path to coverage report
2+
# https://github.com/pytest-dev/pytest-cov/issues/98#issuecomment-451344057

examples/testclient/tests/conftest.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""
2+
fixtures available for injection to tests by pytest
3+
"""
4+
import pytest
5+
from hello.app import conn_app
6+
from starlette.testclient import TestClient
7+
8+
9+
@pytest.fixture
10+
def client():
11+
"""
12+
Create a Connexion test_client from the Connexion app.
13+
14+
https://connexion.readthedocs.io/en/stable/testing.html
15+
"""
16+
client: TestClient = conn_app.test_client()
17+
yield client

examples/testclient/tests/test_app.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from hello import app
2+
from httpx import Response
3+
from pytest_mock import MockerFixture
4+
from starlette.testclient import TestClient
5+
6+
greeting = "greeting"
7+
prefixes = ["openapi", "swagger"]
8+
9+
10+
def test_greeting_success(client: TestClient):
11+
name = "dave"
12+
for prefix in prefixes:
13+
# a body is required in the POST
14+
res: Response = client.post(
15+
f"/{prefix}/{greeting}/{name}", json={"message": "hi"}
16+
)
17+
assert res.status_code == 200
18+
assert name in res.json()[greeting]
19+
20+
21+
def test_greeting_exception(client: TestClient):
22+
name = "dave"
23+
for prefix in prefixes:
24+
# a body is required in the POST
25+
res: Response = client.post(
26+
f"/{prefix}/{greeting}/{name}", json={"message": "crash"}
27+
)
28+
assert res.status_code == 500
29+
assert name in res.json()["detail"]
30+
31+
32+
def test_greeting_invalid(client: TestClient):
33+
name = "dave"
34+
for prefix in prefixes:
35+
# a body is required in the POST
36+
res: Response = client.post(
37+
f"/{prefix}/{greeting}/{name}", json={"message": "invalid"}
38+
)
39+
assert res.status_code == 500
40+
assert "Response body does not conform" in res.json()["detail"]
41+
42+
43+
def test_main(mocker: MockerFixture):
44+
# patch the run-app function to do nothing
45+
mock_run = mocker.patch("hello.app.conn_app.run")
46+
app.main()
47+
mock_run.assert_called()
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import json
2+
from unittest.mock import Mock
3+
4+
import pytest
5+
from connexion.lifecycle import ConnexionResponse
6+
from hello import handle_error
7+
8+
9+
@pytest.mark.asyncio
10+
async def test_handle_error():
11+
# Mock the ConnexionRequest object
12+
mock_req = Mock()
13+
mock_req.url = "http://some/url"
14+
# call the function
15+
conn_resp: ConnexionResponse = await handle_error(mock_req, ValueError("Value"))
16+
assert 500 == conn_resp.status_code
17+
# check structure of response
18+
resp_dict = json.loads(conn_resp.body)
19+
assert "Error" == resp_dict["title"]

examples/testclient/tox.ini

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[tox]
2+
envlist = code
3+
minversion = 2.0
4+
5+
[pytest]
6+
testpaths = tests
7+
asyncio_default_fixture_loop_scope = function
8+
9+
[testenv:code]
10+
basepython = python3
11+
deps=
12+
pytest
13+
pytest-asyncio
14+
pytest-cov
15+
pytest-mock
16+
-r requirements.txt
17+
commands =
18+
# posargs allows running just a single test like this:
19+
# tox -- tests/test_foo.py::test_bar
20+
pytest --cov hello --cov-report term-missing --cov-fail-under=70 {posargs}

0 commit comments

Comments
 (0)