Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions py2http/default_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ def flask_output_mapper(output, **inputs):
return output


def flask_json_output_mapper(output, **inputs):
"""The default output mapper of a Flask app: the output as a JSON response."""
from flask import Response

return Response(
json.dumps(output, cls=JsonRespEncoder), content_type=JSON_CONTENT_TYPE
)


flask_json_output_mapper.content_type = JSON_CONTENT_TYPE


def bottle_output_mapper(output, **inputs):
response.content_type = JSON_CONTENT_TYPE
return json.dumps(output, cls=JsonRespEncoder)
Expand Down Expand Up @@ -171,6 +183,31 @@ def default_error_handler(error: Exception):
return bottle_error_handler(error)


# What the default mappers become under each framework (a mapper a user gave is kept)
_DEFAULT_ERROR_HANDLERS = {
AIOHTTP: aiohttp_error_handler,
BOTTLE: bottle_error_handler,
FLASK: flask_error_handler,
}
_DEFAULT_OUTPUT_MAPPERS = {FLASK: flask_json_output_mapper}


def framework_defaults(framework, *, error_handler, output_mapper):
"""The error handler and output mapper to use under ``framework``: the given ones,
except that the defaults (which only know of Bottle and AIOHTTP) are swapped for
the framework's own, so that no process-wide state decides how a response is made.

>>> framework_defaults(FLASK, error_handler=default_error_handler,
... output_mapper=default_output_mapper)[0] is flask_error_handler
True
"""
if error_handler is default_error_handler:
error_handler = _DEFAULT_ERROR_HANDLERS.get(framework, error_handler)
if output_mapper is default_output_mapper:
output_mapper = _DEFAULT_OUTPUT_MAPPERS.get(framework, output_mapper)
return error_handler, output_mapper


default_configs = {
"app_name": "HTTP Service",
"framework": BOTTLE,
Expand Down
55 changes: 44 additions & 11 deletions py2http/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
default_configs,
DFLT_CONTENT_TYPE,
default_input_mapper,
framework_defaults,
)
from py2http.openapi_utils import (
add_paths_to_spec,
Expand Down Expand Up @@ -127,8 +128,11 @@ def get_input_args_and_kwargs(inputs):
)
framework = _get_framework(configs, default_configs)
input_mapper = config_for("input_mapper")
output_mapper = config_for("output_mapper")
error_handler = config_for("error_handler")
error_handler, output_mapper = framework_defaults(
framework,
error_handler=config_for("error_handler"),
output_mapper=config_for("output_mapper"),
)
header_inputs = config_for("header_inputs", type=dict)
logger = config_for("logger")

Expand Down Expand Up @@ -307,16 +311,29 @@ def mk_routes_and_openapi_specs(funcs, **configs):
AppSpec = Union[Handlers, Dict[str, Union[Handlers, SubAppSpec]]]


#: Configs only the Bottle app honours. A Flask app refuses them rather than ignore
#: them: a silently dropped auth plugin would leave its routes open.
_BOTTLE_ONLY_CONFIGS = ("plugins", "enable_cors", "publish_swagger", "openapi_insecure")


def mk_flask_app(funcs, **configs):
"""A Flask app serving ``funcs``. JSON request bodies only (the raw, binary and
form input mappers read Bottle's request); ``middleware`` is a list of WSGI
wrappers; ``plugins``, ``enable_cors``, ``publish_swagger`` and
``openapi_insecure`` are Bottle's, and raise ``NotImplementedError`` here."""
from flask import Flask

unsupported = [k for k in _BOTTLE_ONLY_CONFIGS if configs.get(k)]
if unsupported:
raise NotImplementedError(
f"The config {unsupported} is not supported for the framework 'flask'"
)
configs = dict(configs, framework=FLASK) # Flask routes whatever the default
routes, openapi_spec = mk_routes_and_openapi_specs(funcs, **configs)
app_name = mk_config("app_name", None, configs, default_configs)
app = Flask(app_name)
middleware = mk_config("middleware", None, configs, default_configs)
# publish_openapi = mk_config('publish_openapi', None, configs, default_configs)
if middleware:
app = middleware(app)
for wrap in mk_config("middleware", None, configs, default_configs) or ():
app.wsgi_app = wrap(app.wsgi_app)
for route in routes:
app.add_url_rule(
route.path,
Expand All @@ -325,14 +342,16 @@ def mk_flask_app(funcs, **configs):
methods=[route.http_method.upper()],
)
app.add_url_rule("/ping", "ping", lambda: {"ping": "pong"})
app.add_url_rule("/openapi", "openapi", lambda: openapi_spec)
if mk_config("publish_openapi", None, configs, default_configs):
app.add_url_rule("/openapi", "openapi", lambda: openapi_spec)
app.openapi_spec = openapi_spec
return app


def mk_bottle_app(funcs, **configs):
from bottle import Bottle

configs = dict(configs, framework=BOTTLE)
routes, openapi_spec = mk_routes_and_openapi_specs(funcs, **configs)
app = Bottle(catchall=False)
enable_cors = mk_config("enable_cors", None, configs, default_configs)
Expand Down Expand Up @@ -524,7 +543,9 @@ def get_web_framework_objects():
elif framework == AIOHTTP:
app = web.Application()
return app, app.add_subapp
return None
raise NotImplementedError(
f"A multi-api app is not supported for the framework {framework!r}"
)

parent_app, add_subapp_meth = get_web_framework_objects()
for route, route_spec in app_spec.items():
Expand Down Expand Up @@ -559,6 +580,14 @@ def get_web_framework_objects():
return mk_single_api_app()


def run_flask(app, *, host, port, certfile=None, keyfile=None, **_):
"""Run a Flask app with Flask's own server (``server`` does not apply to it)."""
if bool(certfile) != bool(keyfile):
raise ValueError("Give both ssl_certfile and ssl_keyfile, or neither.")
ssl_context = (certfile, keyfile) if certfile else None
app.run(host=host, port=port, ssl_context=ssl_context)


@Sig.add_optional_keywords(default_configs)
def run_app(app_obj: Union[AppSpec, Any], **configs):
"""
Expand All @@ -582,6 +611,8 @@ def get_run_func():
framework = _get_framework(configs, default_configs)
if framework == BOTTLE:
return run_bottle
elif framework == FLASK:
return run_flask
elif framework == AIOHTTP:
return web.run_app
raise NotImplementedError("")
Expand Down Expand Up @@ -611,11 +642,13 @@ def get_run_func():
)


_SUPPORTED_FRAMEWORKS = (BOTTLE, FLASK)


def _get_framework(configs, default_configs):
framework = mk_config("framework", None, configs, default_configs)
# NOTE Only support Bottle until we redesign py2http using a reusable tool for routing
# if framework not in (FLASK, BOTTLE, AIOHTTP):
if framework != BOTTLE:
# NOTE Bottle and Flask only, until py2http is redesigned on a reusable routing tool
if framework not in _SUPPORTED_FRAMEWORKS:
raise NotImplementedError(
f'The Web Framework "{framework}" is not supported by py2http'
)
Expand Down
86 changes: 86 additions & 0 deletions py2http/tests/test_error_statuses.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
)

from py2http import mk_app
from py2http.service import mk_flask_app
from py2http.default_configs import aiohttp_error_handler, error_status
from py2http.tests.utils_for_testing import call_wsgi

Expand Down Expand Up @@ -101,3 +102,88 @@ def test_flask_opaque_errors_hides_500_text(monkeypatch):
"/f", "f", lambda: flask_error_handler(KeyError("internal detail"))
)
assert app.test_client().get("/f").get_json() == {"error": "Internal server error"}


@pytest.mark.parametrize("error, status", CASES)
def test_flask_app_statuses(error, status, monkeypatch, capsys):
"""``mk_app(framework='flask')``: statuses and challenges, end to end."""
pytest.importorskip("flask")
monkeypatch.delenv("PY2HTTP_WWW_AUTHENTICATE", raising=False)

def fail():
raise error

app = mk_app([fail], framework="flask")
response = app.test_client().post("/fail", json={})
assert response.status_code == status
assert set(response.get_json()) == {"error"}
if status == 400:
assert response.status == f"400 {type(error).__name__}"
if status == 401:
assert response.headers["WWW-Authenticate"] == "Bearer"
else:
assert "WWW-Authenticate" not in response.headers


def test_flask_app_answers_json():
pytest.importorskip("flask")

def add(a, b: int = 0):
return {"sum": a + b}

for app in (
mk_app([add], framework="flask"),
mk_flask_app([add]), # called directly: still flask routes
):
response = app.test_client().post("/add", json={"a": 1, "b": 2})
assert response.status_code == 200
assert response.content_type.startswith("application/json")
assert response.get_json() == {"sum": 3}


def test_flask_does_not_leak_into_a_bottle_app(capsys):
pytest.importorskip("flask")
bottle_app = _app_raising(NotFoundError("x"))
mk_app([_app_raising], framework="flask") # sets the process-wide framework
got, _ = call_wsgi(bottle_app, "/fail", {})
assert got.startswith("404") # still bottle's way of answering


def test_unsupported_framework_is_refused():
with pytest.raises(NotImplementedError):
mk_app([_app_raising], framework="django")


def test_flask_refuses_what_it_would_ignore():
"""An auth plugin dropped silently would leave the routes open."""
pytest.importorskip("flask")
for config in ({"plugins": [object()]}, {"enable_cors": True}):
with pytest.raises(NotImplementedError):
mk_app([_app_raising], framework="flask", **config)


def test_flask_openapi_only_when_published():
pytest.importorskip("flask")

def f():
return 1

assert mk_app([f], framework="flask").test_client().get("/openapi").status_code == 404
published = mk_app([f], framework="flask", publish_openapi=True)
assert published.test_client().get("/openapi").status_code == 200


def test_flask_middleware_wraps_the_wsgi_app():
pytest.importorskip("flask")
seen = []

def wrap(wsgi_app):
def wrapped(environ, start_response):
seen.append(environ["PATH_INFO"])
return wsgi_app(environ, start_response)

return wrapped

app = mk_app([_app_raising], framework="flask", middleware=[wrap])
app.test_client().post("/_app_raising", json={})
assert seen == ["/_app_raising"]
Loading