Skip to content

Commit 3884fe2

Browse files
authored
update to connexion 3.x (#3426)
* Replace uwsgi with uvicorn * Update pinned dependencies * admin: port connexion App creation to connexion 3 - use SwaggerUIOptions - remove BalrogRequestBodyValidator, let connexion do the validation * public: update app instanciation for connexion 3 * web: work around TypeError from connexion's ServerErrorMiddleware See spec-first/connexion#2059 * tests: remove check for error text in testDeleteRequiredSignoff connexion now rejects those requests due to extra params, so the error is different. * tests: remove 404 test for paths that now work * test: don't send control characters the httpx client rejects those urls * tests: port to new APIs The test client for the connexion app is a starlette TestClient instead of a flask client, with a different API for responses: - `text` attribute instead of `get_data()` method or `data` attribute - `json()` method instead of `get_json()` method or `json` attribute - no `mimetype` attribute, so we look up the `content-type` header instead and requests: - `params` argument replaces `query_string` * tests: propagate exceptions through connexion middlewares * admin: move CORS handling to starlette CORSMiddleware CORS checks need to happen before routing, otherwise preflight requests get a 405. * admin: port statsd timers to a middleware * public: port statsd request handler to a middleware
2 parents db1579a + 1684803 commit 3884fe2

40 files changed

+1469
-835
lines changed

Dockerfile

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,10 @@ ENV LC_ALL C.UTF-8
66

77
LABEL maintainer="[email protected]"
88

9-
# uwsgi needs libpcre3 for routing support to be enabled.
109
# default-libmysqlclient-dev is required to use SQLAlchemy with MySQL, which we do in production.
1110
# xz-utils is needed to compress production database dumps
1211
RUN apt-get -q update \
13-
&& apt-get -q --yes install libpcre3-dev default-libmysqlclient-dev mariadb-client xz-utils pkg-config \
12+
&& apt-get -q --yes install default-libmysqlclient-dev mariadb-client xz-utils pkg-config \
1413
&& apt-get clean
1514

1615
WORKDIR /app
@@ -29,7 +28,7 @@ RUN apt-get install -q --yes gcc && \
2928
# Copying Balrog to /app instead of installing it means that production can run
3029
# it, and we can bind mount to override it for local development.
3130
COPY src/ /app/src/
32-
COPY uwsgi/ /app/uwsgi/
31+
COPY uvicorn/ /app/uvicorn/
3332
COPY scripts/manage-db.py scripts/run-batch-deletes.sh scripts/run.sh scripts/reset-stage-db.sh scripts/get-prod-db-dump.py /app/scripts/
3433
COPY MANIFEST.in pyproject.toml setup.py version.json version.txt /app/
3534

Dockerfile.test

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@ ENV LC_ALL C.UTF-8
66
LABEL maintainer="[email protected]"
77

88
# netcat is needed for health checks
9-
# Some versions of the python:3.8 Docker image remove libpcre3, which uwsgi needs for routing support to be enabled.
109
# default-libmysqlclient-dev is required to use SQLAlchemy with MySQL
1110
# mariadb-client is needed to import sample data
1211
# curl is needed to pull sample data
1312
# gcc is needed to compile some python packages
1413
# xz-utils is needed to unpack sampled ata
1514
RUN apt-get -q update \
16-
&& apt-get -q --yes install g++ netcat-traditional libpcre3 libpcre3-dev default-libmysqlclient-dev mariadb-client curl gcc xz-utils pkg-config \
15+
&& apt-get -q --yes install g++ netcat-traditional default-libmysqlclient-dev mariadb-client curl gcc xz-utils pkg-config \
1716
&& apt-get clean
1817

1918
WORKDIR /app
@@ -26,7 +25,7 @@ RUN pip install --no-deps -r requirements/local.txt
2625
COPY src/ /app/src/
2726
COPY tests/ /app/tests/
2827
COPY scripts/ /app/scripts/
29-
COPY uwsgi/ /app/uwsgi/
28+
COPY uvicorn/ /app/uvicorn/
3029
COPY MANIFEST.in setup.py pyproject.toml tox.ini version.json version.txt /app/
3130

3231
RUN pip install -e .

MANIFEST.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ include version.txt
66

77
recursive-include src *
88
recursive-include requirements *.txt
9-
recursive-include uwsgi *
9+
recursive-include uvicorn *
1010

1111
exclude .dirschema.yml
1212
exclude .editorconfig

requirements/base.in

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
aiohttp
22
arrow
33
auth0-python>=4
4-
connexion<3
4+
connexion[flask,swagger-ui,uvicorn]>3
55
deepmerge
66
ecdsa
77
flask
@@ -10,6 +10,7 @@ gcloud-aio-storage
1010
google-api-core
1111
google-auth
1212
google-cloud-storage
13+
gunicorn
1314
jsonschema>=4.5.0
1415
mozilla-version
1516
mysqlclient
@@ -25,4 +26,3 @@ sqlalchemy-migrate
2526
sqlalchemy<2
2627
statsd
2728
swagger-spec-validator
28-
uwsgi

requirements/base.txt

Lines changed: 350 additions & 24 deletions
Large diffs are not rendered by default.

requirements/docs.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
aiohttp
22
arrow
33
auth0-python
4-
connexion<3 # Just to be consistent with base.in
4+
connexion[flask,swagger-ui,uvicorn]>3
55
deepmerge
66
flask
77
flask_wtf

requirements/docs.txt

Lines changed: 342 additions & 21 deletions
Large diffs are not rendered by default.

scripts/run.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
#!/bin/bash
22

33
if [ $1 == "public" ]; then
4-
exec uwsgi --ini /app/uwsgi/public.ini --python-autoreload 1
4+
exec gunicorn -k uvicorn.workers.UvicornWorker -b "${HOST}:${PORT}" --chdir /app/uvicorn --reload public:connexion_app
55
elif [ $1 == "admin" ]; then
6-
exec uwsgi --ini /app/uwsgi/admin.ini --python-autoreload 1
6+
exec gunicorn -k uvicorn.workers.UvicornWorker -b "${HOST}:${PORT}" --chdir /app/uvicorn --reload admin:connexion_app
77
elif [ $1 == "create-db" ]; then
88
if [ -z "${DBURI}" ]; then
99
echo "\${DBURI} must be set!"

src/auslib/web/admin/base.py

Lines changed: 57 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,22 @@
33
from os import path
44

55
import connexion
6-
from flask import g, request
6+
from connexion.middleware import MiddlewarePosition
7+
from connexion.options import SwaggerUIOptions
8+
from flask import request
79
from sentry_sdk import capture_exception
810
from specsynthase.specbuilder import SpecBuilder
11+
from starlette.middleware.cors import CORSMiddleware
912
from statsd.defaults.env import statsd
1013

1114
import auslib
15+
import auslib.web.admin.views.validators # noqa
1216
from auslib.db import ChangeScheduledError, OutdatedDataError, UpdateMergeError
1317
from auslib.dockerflow import create_dockerflow_endpoints
1418
from auslib.errors import BlobValidationError, PermissionDeniedError, ReadOnlyError, SignoffRequiredError
1519
from auslib.util.auth import AuthError, verified_userinfo
1620
from auslib.web.admin.views.problem import problem
17-
from auslib.web.admin.views.validators import BalrogRequestBodyValidator
21+
from auslib.web.common import middlewares
1822

1923
log = logging.getLogger(__name__)
2024

@@ -29,50 +33,54 @@
2933
.add_spec(path.join(web_dir, "common/swagger/responses.yml"))
3034
)
3135

32-
validator_map = {"body": BalrogRequestBodyValidator}
33-
34-
35-
def should_time_request():
36-
# don't time OPTIONS requests
37-
if request.method == "OPTIONS":
38-
return False
39-
# don't time requests that don't match a valid route
40-
if request.url_rule is None:
41-
return False
42-
# don't time dockerflow endpoints
43-
if request.path.startswith("/__"):
44-
return False
45-
46-
return True
47-
48-
49-
def create_app():
50-
connexion_app = connexion.App(__name__, debug=False, options={"swagger_ui": False})
51-
connexion_app.add_api(spec, validator_map=validator_map, strict_validation=True)
36+
swagger_ui_options = SwaggerUIOptions(swagger_ui=False)
37+
38+
39+
class StatsdMiddleware:
40+
def __init__(self, app):
41+
self.app = app
42+
43+
def metric_name(self, scope):
44+
if scope["method"] == "OPTIONS":
45+
return
46+
op = scope.get("extensions", {}).get("connexion_routing", {}).get("operation_id")
47+
if op is None:
48+
return
49+
# do some massaging to get the metric name right
50+
# * remove various module prefixes
51+
# * add a common prefix to ensure that we can mark these metrics as gauges for
52+
# statsd
53+
metric = op.replace(".", "_").removeprefix("auslib_web_admin_views_").removeprefix("auslib_web_admin_").removeprefix("auslib_web_common_")
54+
return f"endpoint_{metric}"
55+
56+
async def __call__(self, scope, receive, send):
57+
if scope["type"] != "http":
58+
await self.app(scope, receive, send)
59+
return
60+
61+
metric = self.metric_name(scope)
62+
if not metric:
63+
await self.app(scope, receive, send)
64+
return
65+
66+
timer = statsd.timer(metric)
67+
timer.start()
68+
try:
69+
await self.app(scope, receive, send)
70+
finally:
71+
timer.stop()
72+
73+
74+
def create_app(allow_origins=None):
75+
connexion_app = connexion.App(__name__, swagger_ui_options=swagger_ui_options, middlewares=middlewares[:])
76+
connexion_app.app.debug = False
77+
connexion_app.add_api(spec, strict_validation=True)
5278
connexion_app.add_api(path.join(current_dir, "swagger", "api_v2.yml"), base_path="/v2", strict_validation=True, validate_responses=True)
79+
connexion_app.add_middleware(StatsdMiddleware, MiddlewarePosition.BEFORE_VALIDATION)
5380
flask_app = connexion_app.app
5481

5582
create_dockerflow_endpoints(flask_app)
5683

57-
@flask_app.before_request
58-
def setup_timer():
59-
g.request_timer = None
60-
if should_time_request():
61-
# do some massaging to get the metric name right
62-
# * get rid of the `/v2` prefix on v2 endpoints added by `base_path` further up
63-
# * remove various module prefixes
64-
# * add a common prefix to ensure that we can mark these metrics as gauges for
65-
# statsd
66-
metric = (
67-
request.url_rule.endpoint.removeprefix("/v2.")
68-
.removeprefix("auslib_web_admin_views_")
69-
.removeprefix("auslib_web_admin_")
70-
.removeprefix("auslib_web_common_")
71-
)
72-
metric = f"endpoint_{metric}"
73-
g.request_timer = statsd.timer(metric)
74-
g.request_timer.start()
75-
7684
@flask_app.before_request
7785
def setup_request():
7886
if request.full_path.startswith("/v2"):
@@ -182,12 +190,6 @@ def add_security_headers(response):
182190
response.headers["X-Frame-Options"] = "DENY"
183191
response.headers["X-Content-Type-Options"] = "nosniff"
184192
response.headers["Strict-Transport-Security"] = flask_app.config.get("STRICT_TRANSPORT_SECURITY", "max-age=31536000;")
185-
response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
186-
response.headers["Access-Control-Allow-Methods"] = "OPTIONS, GET, POST, PUT, DELETE"
187-
if "*" in flask_app.config["CORS_ORIGINS"]:
188-
response.headers["Access-Control-Allow-Origin"] = "*"
189-
elif "Origin" in request.headers and request.headers["Origin"] in flask_app.config["CORS_ORIGINS"]:
190-
response.headers["Access-Control-Allow-Origin"] = request.headers["Origin"]
191193
if re.match("^/ui/", request.path):
192194
# This enables swagger-ui to dynamically fetch and
193195
# load the swagger specification JSON file containing API definition and examples.
@@ -196,14 +198,13 @@ def add_security_headers(response):
196198
response.headers["Content-Security-Policy"] = flask_app.config.get("CONTENT_SECURITY_POLICY", "default-src 'none'; frame-ancestors 'none'")
197199
return response
198200

199-
# this is specifically set-up last before after_request handlers are called
200-
# in reverse order of registering, and we want this one to be called first
201-
# to avoid it being skipped if another one raises an exception
202-
@flask_app.after_request
203-
def send_stats(response):
204-
if hasattr(g, "request_timer") and g.request_timer:
205-
g.request_timer.stop()
206-
207-
return response
201+
if allow_origins:
202+
connexion_app.add_middleware(
203+
CORSMiddleware,
204+
MiddlewarePosition.BEFORE_ROUTING,
205+
allow_origins=allow_origins,
206+
allow_headers=["Authorization", "Content-Type"],
207+
allow_methods=["OPTIONS", "GET", "POST", "PUT", "DELETE"],
208+
)
208209

209210
return connexion_app

src/auslib/web/admin/views/validators.py

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,7 @@
1-
import logging
2-
from typing import AnyStr, Union
3-
4-
import jsonschema
5-
from connexion.decorators.validation import RequestBodyValidator
6-
from connexion.exceptions import BadRequestProblem
7-
from connexion.lifecycle import ConnexionResponse
8-
from connexion.utils import is_null
9-
101
# To enable shared jsonschema validators
112
import auslib.util.jsonschema_validators # noqa
123
from auslib.util.timestamp import getMillisecondTimestamp
134

14-
logger = logging.getLogger(__name__)
15-
16-
17-
class BalrogRequestBodyValidator(RequestBodyValidator):
18-
def validate_schema(self, data: dict, url: AnyStr) -> Union[ConnexionResponse, None]:
19-
"""This function is largely based on https://github.com/zalando/connexion/blob/master/connexion/decorators/validation.py
20-
and should largely be kept in line with it."""
21-
if self.is_null_value_valid and is_null(data):
22-
return None
23-
try:
24-
self.validator.validate(data)
25-
except jsonschema.ValidationError as exception:
26-
# Add field name to the error response
27-
exception_field = ""
28-
for i in exception.path:
29-
exception_field = i + ": "
30-
if exception.__cause__ is not None:
31-
exception_message = exception.__cause__.message + " " + exception_field + exception.message
32-
else:
33-
exception_message = exception_field + exception.message
34-
# Some exceptions could contain unicode characters - if we don't replace them
35-
# we could end up with a UnicodeEncodeError.
36-
logger.error("{url} validation error: {error}".format(url=url, error=exception_message.encode("utf-8", "replace")))
37-
raise BadRequestProblem(detail=exception_message)
38-
39-
return None
40-
415

426
def is_when_present_and_in_past_validator(what):
437
"""Validates if scheduled_change_time value i.e. 'when' field value is present in

0 commit comments

Comments
 (0)