Skip to content

Commit 3088c4d

Browse files
committed
Add example of using the Starlette test client with tox
This example Connexion app processes JSON requests and responses as specified with OpenAPI v2 (aka Swagger) or OpenAPI v3 file format. The app asks Connexion to validate JSON responses against the spec. The app defines an error handler that catches exceptions raised while processing a request. Define automated tests that are run by tox. Measure code lines covered by the automated tests.
1 parent 1d4bb81 commit 3088c4d

File tree

9 files changed

+290
-0
lines changed

9 files changed

+290
-0
lines changed

examples/testclient/README.rst

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
===================
2+
Test Client Example
3+
===================
4+
5+
This directory offers a Connexion app that processes a single JSON
6+
request and generates a response as specified with OpenAPI v2 (aka
7+
Swagger) or OpenAPI v3 file format. This example app demonstrates
8+
these test and validation features:
9+
* Validate generated responses against the specification
10+
* Catch and report exceptions raised while processing a request
11+
* Use tox and the Starlette test client to test the app automatically
12+
* Measure and report code lines covered by the automated tests
13+
14+
Preparing
15+
---------
16+
17+
Create a new virtual environment and install the required libraries
18+
with these commands:
19+
20+
.. code-block:: bash
21+
22+
$ python -m venv my-venv
23+
$ source my-venv/bin/activate
24+
$ pip install 'connexion[flask,swagger-ui,uvicorn]>=3.1.0' tox
25+
26+
Testing
27+
-------
28+
29+
Run the test suite and generate the coverage report with this command:
30+
31+
.. code-block:: bash
32+
33+
$ tox
34+
35+
Running
36+
-------
37+
38+
Launch the connexion server with this command:
39+
40+
.. code-block:: bash
41+
42+
$ python app.py
43+
44+
Now open your browser and view the Swagger UI for these specification files:
45+
46+
* http://localhost:8080/openapi/ui/ for the OpenAPI 3 spec
47+
* http://localhost:8080/swagger/ui/ for the Swagger 2 spec
48+
49+
Demonstrating
50+
-------------
51+
52+
In the Swagger UI, click the "Try it out" button. Send a request name and
53+
the app responds with a 200 status code and a greeting message "Hello <name>".
54+
Next, demonstrate the app's validation features by sending a request with
55+
one of the following values in the request body's `message` parameter:
56+
* `crash` - the app raises an exception, which is caught by the exception handler;
57+
you will see an RFC 7807 "problem" response with `type`, `title`, `detail`
58+
and `status` fields with the exception message.
59+
* `invalid` - the app generates an invalid response, which is caught by Connexion;
60+
you will again see an RFC 7807 "problem" response with the failed-validation message.

examples/testclient/app.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import logging
2+
from pathlib import Path
3+
4+
from connexion import FlaskApp
5+
from connexion.lifecycle import ConnexionRequest, ConnexionResponse
6+
from connexion.problem import problem
7+
8+
# reuse the configured logger for simplicity
9+
logger = logging.getLogger("uvicorn.error")
10+
11+
12+
def handle_error(request: ConnexionRequest, ex: Exception) -> ConnexionResponse:
13+
"""
14+
Report an error that happened while processing a request.
15+
See: https://connexion.readthedocs.io/en/latest/exceptions.html
16+
17+
:param request: Request that failed
18+
:parm ex: Exception that was raised
19+
:return: ConnexionResponse with RFC7087 problem details
20+
"""
21+
# log the request URL, exception and stack trace
22+
logger.exception(
23+
"Connexion caught exception on request to %s", request.url, exc_info=ex
24+
)
25+
return problem(title="Error", status=500, detail=repr(ex))
26+
27+
28+
def create_app() -> FlaskApp:
29+
"""
30+
Create the connexion.FlaskApp, which wraps a Flask app.
31+
32+
:return Newly created connexion.FlaskApp
33+
"""
34+
app = FlaskApp(__name__, specification_dir="spec/")
35+
# hook the functions to the OpenAPI spec
36+
title = {"title": "Hello World Plus Example"}
37+
app.add_api("openapi.yaml", arguments=title, validate_responses=True)
38+
app.add_api("swagger.yaml", arguments=title, validate_responses=True)
39+
# hook a function that is invoked on any exception
40+
app.add_error_handler(Exception, handle_error)
41+
# return the fully initialized connexion.FlaskApp
42+
return app
43+
44+
45+
def post_greeting(name: str, body: dict) -> tuple:
46+
logger.info(
47+
"%s: name len %d, body items %d", post_greeting.__name__, len(name), len(body)
48+
)
49+
# the body is optional
50+
message = body.get("message", None)
51+
if "crash" == message:
52+
msg = f"Found message {message}, raise ValueError"
53+
logger.info("%s", msg)
54+
raise ValueError(msg)
55+
if "invalid" == message:
56+
logger.info("Found message %s, return invalid response", message)
57+
return {"bogus": "response"}
58+
return {"greeting": f"Hello {name}"}, 200
59+
60+
61+
# define app so loader can find it
62+
conn_app = create_app()
63+
if __name__ == "__main__":
64+
conn_app.run(f"{Path(__file__).stem}:conn_app", port=8080)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
connexion[flask,swagger-ui,uvicorn]
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: 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: 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"
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
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""
2+
fixtures available for injection to tests by pytest
3+
"""
4+
import pytest
5+
from starlette.testclient import TestClient
6+
7+
from app import conn_app
8+
9+
10+
@pytest.fixture
11+
def client():
12+
"""
13+
Create a Connexion test_client from the Connexion app.
14+
15+
https://connexion.readthedocs.io/en/stable/testing.html
16+
"""
17+
client: TestClient = conn_app.test_client()
18+
yield client
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from httpx import Response
2+
from starlette.testclient import TestClient
3+
4+
detail = "detail"
5+
greeting = "greeting"
6+
message = "message"
7+
prefixes = ["openapi", "swagger"]
8+
9+
10+
def test_greeting_dave(client: TestClient):
11+
name = "dave"
12+
for prefix in prefixes:
13+
res: Response = client.post(
14+
f"/{prefix}/{greeting}/{name}", json={message: "hi"}
15+
)
16+
assert res.status_code == 200
17+
assert name in res.json()[greeting]
18+
19+
20+
def test_greeting_crash(client: TestClient):
21+
crash = "crash"
22+
for prefix in prefixes:
23+
res: Response = client.post(f"/{prefix}/{greeting}/name", json={message: crash})
24+
assert res.status_code == 500
25+
assert crash in res.json()[detail]
26+
27+
28+
def test_greeting_invalid(client: TestClient):
29+
for prefix in prefixes:
30+
# a body is required in the POST
31+
res: Response = client.post(
32+
f"/{prefix}/{greeting}/name", json={message: "invalid"}
33+
)
34+
assert res.status_code == 500
35+
assert "Response body does not conform" in res.json()[detail]

examples/testclient/tox.ini

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

0 commit comments

Comments
 (0)