Skip to content

Commit 3ade3ab

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.
1 parent 1d4bb81 commit 3ade3ab

File tree

9 files changed

+287
-0
lines changed

9 files changed

+287
-0
lines changed

examples/testclient/README.rst

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

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/spec/openapi.yaml

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

examples/testclient/spec/swagger.yaml

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"

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 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: 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: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
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-mock
13+
-r requirements.txt
14+
commands =
15+
# posargs allows running just a single test like this:
16+
# tox -- tests/test_foo.py::test_bar
17+
pytest {posargs}

0 commit comments

Comments
 (0)