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
14 changes: 10 additions & 4 deletions py2http/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -1225,10 +1225,13 @@ def _request_body(request) -> bytes:


def _request_files(request) -> dict:
"""``{field name: bytes}`` of the uploaded files of a Bottle or Flask request."""
"""``{field name: bytes}`` of the uploaded files of a Bottle or Flask request. A field
with several files gives the ``list`` of their bytes, in upload order."""
if hasattr(request, "get_data"): # Flask: werkzeug FileStorage
return {k: v.read() for k, v in request.files.items()}
return {k: v.file.read() for k, v in request.files.items()}
groups = {k: [f.read() for f in request.files.getlist(k)] for k in request.files}
else:
groups = {k: [f.file.read() for f in request.files.getall(k)] for k in request.files}
return {k: v[0] if len(v) == 1 else v for k, v in groups.items()}


def _loads_mapping(data: bytes, what: str) -> dict:
Expand Down Expand Up @@ -1275,7 +1278,10 @@ def _get_inputs_from_request(request, content_type, *, binary_loads=None):
binaries = _request_files(request)
if "__fields" not in binaries:
raise InputError("The form request has no '__fields' part.")
fields = _loads_mapping(binaries.pop("__fields"), "'__fields' part")
fields_part = binaries.pop("__fields")
if isinstance(fields_part, list):
raise InputError("The form request has several '__fields' parts; expected one.")
fields = _loads_mapping(fields_part, "'__fields' part")
inputs = dict(fields, **binaries)
return dict(defaults, **inputs)
else:
Expand Down
65 changes: 54 additions & 11 deletions py2http/default_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from aiohttp import web
from bottle import response
import inspect
import json
import logging
import os
Expand Down Expand Up @@ -67,13 +68,34 @@ def flask_json_output_mapper(output, **inputs):
flask_json_output_mapper.content_type = JSON_CONTENT_TYPE


def _flask_response(result, content_type):
"""``result`` as a Flask response of ``content_type`` if it is a ``str``/``bytes`` body, or
a ``(body, status[, headers])`` tuple of one; anything else (a ``Response``, a dict) is kept."""
from flask import Response
from werkzeug.datastructures import Headers

if isinstance(result, (str, bytes)):
return Response(result, content_type=content_type)
if isinstance(result, tuple) and result and isinstance(result[0], (str, bytes)):
body, *rest = result
status = rest[0] if rest and isinstance(rest[0], (int, str)) else None
headers = next((r for r in rest if r is not None and not isinstance(r, (int, str))), None)
response = Response(body, status=status, headers=headers)
if not any(k.lower() == "content-type" for k, _ in Headers(headers or {}).items()):
response.content_type = content_type
return response
return result


def flask_response_mapper(output_mapper):
"""Make a Flask view's response of what an output mapper returns.

The ``send_*_resp`` decorators set Bottle's ``response.content_type`` and return
the body (``str`` or ``bytes``); Flask ignores that and would answer ``text/html``.
A mapper that declares a ``content_type`` and returns a ``str``/``bytes`` body gets
that content type; anything else (a ``Response``, a dict, a tuple) is kept as is.
A mapper that declares a ``content_type`` and returns a ``str``/``bytes`` body, or a
``(body, status[, headers])`` tuple of one, gets that content type (unless the tuple's
headers set their own); an ``async`` mapper is awaited first. Anything else (a
``Response``, a dict) is kept as is.

>>> from py2http.decorators import send_raw_resp # (needs flask)
>>> flask_response_mapper(send_raw_resp(lambda o: o))(b"x").content_type # doctest: +SKIP
Expand All @@ -83,14 +105,24 @@ def flask_response_mapper(output_mapper):
if content_type is None:
return output_mapper

@wraps(output_mapper)
def mapper(output, **inputs):
result = output_mapper(output, **inputs)
if isinstance(result, (str, bytes)):
from flask import Response
if inspect.iscoroutinefunction(output_mapper):

@wraps(output_mapper)
async def mapper(output, **inputs):
return _flask_response(await output_mapper(output, **inputs), content_type)

return Response(result, content_type=content_type)
return result
else:

@wraps(output_mapper)
def mapper(output, **inputs):
result = output_mapper(output, **inputs)
if inspect.isawaitable(result): # a sync wrapper around an async mapper

async def settle():
return _flask_response(await result, content_type)

return settle()
return _flask_response(result, content_type)

return mapper

Expand Down Expand Up @@ -195,10 +227,20 @@ def flask_error_handler(error: Exception):
the bottle handler (``OPAQUE_ERRORS`` hides the text of a 500)."""
message = str(error)
status = error_status(error)
code = getattr(error, "code", None) # werkzeug's own 4xx, e.g. 413 past MAX_CONTENT_LENGTH
if status == 500 and isinstance(code, int) and 400 <= code < 500 and hasattr(error, "get_response"):
status, message = code, getattr(error, "description", None) or message
if status == 500 and os.getenv("OPAQUE_ERRORS", None):
message = "Internal server error"
status_line = f"400 {type(error).__name__}" if status == 400 else status
return {"error": message}, status_line, _status_headers(status)
headers = _status_headers(status)
if hasattr(error, "get_response") and status == code: # e.g. the ``Allow`` of a 405
headers.update(
(k, v)
for k, v in error.get_response().headers
if k.lower() not in ("content-type", "content-length")
)
return {"error": message}, status_line, headers


def default_error_handler(error: Exception):
Expand Down Expand Up @@ -255,7 +297,8 @@ def framework_defaults(framework, *, error_handler, output_mapper):
"logger": None,
"plugins": [],
"enable_cors": False,
# Bottle only: max bytes of an in-memory request body (None keeps bottle's 100 KiB).
# Max bytes of a request body. Bottle: the in-memory limit (None keeps bottle's 100 KiB).
# Flask: sets MAX_CONTENT_LENGTH (None keeps Flask's default: no limit).
# A bigger request is answered "413 Request entity too large".
"max_request_size": None,
"cors_allowed_origins": "*",
Expand Down
3 changes: 3 additions & 0 deletions py2http/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,9 @@ def mk_flask_app(funcs, **configs):
plugins = check_flask_plugins(get_config("plugins"))
routes, openapi_spec = mk_routes_and_openapi_specs(funcs, **configs)
app = Flask(get_config("app_name"))
max_request_size = get_config("max_request_size")
if max_request_size is not None:
app.config["MAX_CONTENT_LENGTH"] = int(max_request_size)
for wrap in get_config("middleware") or ():
app.wsgi_app = wrap(app.wsgi_app)
enable_cors = get_config("enable_cors")
Expand Down
81 changes: 81 additions & 0 deletions py2http/tests/test_flask_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,84 @@ def test_bottle_bad_body_is_400():
def test_json_encodings_and_content_type_case(body, content_type):
r = _client(add).post("/add", data=body, content_type=content_type)
assert (r.status_code, r.get_json()) == (200, 3)


# --- i2mint/py2http#44 -----------------------------------------------------------------


def _mapper_with_content_type(fn, content_type=RAW_CONTENT_TYPE, *, is_async=False):
if is_async:

async def mapper(output, **inputs):
return fn(output)

else:

def mapper(output, **inputs):
return fn(output)

mapper.content_type = content_type
return mapper


@pytest.mark.parametrize("is_async", [False, True])
@pytest.mark.parametrize(
"returned, status, expected",
[("hi", 200, b"hi"), (("hi", 201), 201, b"hi"), ((b"hi", 202, {"X-A": "1"}), 202, b"hi")],
)
def test_output_mapper_content_type_survives_async_and_status_tuples(
is_async, returned, status, expected
):
mapper = _mapper_with_content_type(lambda out: returned, is_async=is_async)
r = _client(add, output_mapper=mapper).post("/add", json={"x": 1})
assert (r.status_code, r.data) == (status, expected)
assert r.content_type.split(";")[0] == RAW_CONTENT_TYPE


def test_a_tuple_with_its_own_content_type_header_keeps_it():
mapper = _mapper_with_content_type(lambda out: ("hi", 200, {"Content-Type": "text/csv"}))
r = _client(add, output_mapper=mapper).post("/add", json={"x": 1})
assert r.content_type.split(";")[0] == "text/csv"


def sizes(data: bytes):
return [len(d) for d in data] if isinstance(data, list) else len(data)


def _post_files(*files):
import io

mapper = handle_form_req(lambda data: dict(data=data))
data = {"__fields": (io.BytesIO(b"{}"), "__fields")}
data["data"] = [(io.BytesIO(b), "data") for b in files] if len(files) > 1 else (io.BytesIO(files[0]), "data")
return _client(sizes, input_mapper=mapper).post("/sizes", data=data, content_type="multipart/form-data")


def test_several_files_under_one_form_field_are_all_kept():
assert _post_files(b"a", b"bb", b"ccc").get_json() == [1, 2, 3]
assert _post_files(b"abcd").get_json() == 4 # one file stays a bare bytes


def test_max_request_size_sets_flask_max_content_length():
app = mk_app([add], framework="flask", max_request_size=1000)
assert app.config["MAX_CONTENT_LENGTH"] == 1000
r = app.test_client().post("/add", data=b"x" * 2000, content_type=JSON_CONTENT_TYPE)
assert r.status_code == 413
assert app.test_client().post("/add", json={"x": 1}).status_code == 200
assert mk_app([add], framework="flask").config["MAX_CONTENT_LENGTH"] is None


def test_a_tuple_with_werkzeug_headers_keeps_them():
from werkzeug.datastructures import Headers

mapper = _mapper_with_content_type(lambda out: ("hi", 201, Headers([("X-A", "1")])))
r = _client(add, output_mapper=mapper).post("/add", json={"x": 1})
assert (r.status_code, r.headers["X-A"]) == (201, "1")


def test_a_werkzeug_4xx_keeps_the_headers_it_carries():
from werkzeug.exceptions import MethodNotAllowed
from py2http.default_configs import flask_error_handler

_, status, headers = flask_error_handler(MethodNotAllowed(["POST"]))
assert status == 405 and headers["Allow"] == "POST"
Loading