diff --git a/py2http/decorators.py b/py2http/decorators.py index 4708bad..698c67c 100644 --- a/py2http/decorators.py +++ b/py2http/decorators.py @@ -29,7 +29,7 @@ PK, KO, ) -from i2.errors import ModuleNotFoundIgnore +from i2.errors import InputError, ModuleNotFoundIgnore from py2http.schema_tools import ( mk_input_schema_from_func, @@ -1012,12 +1012,13 @@ def _handle_req(func, content_type, *, binary_loads=None): func.request_schema = mk_input_schema_from_func(func) func.content_type = content_type - # TODO: make this work with Bottle @wraps(func) def input_mapper(req): - if content_type not in req.content_type: - raise RuntimeError(f"The incoming request's content is of type \ -{req.content_type}, when {content_type} is expected.") + if content_type not in (req.content_type or "").lower(): + raise InputError( + f"The incoming request's content is of type " + f"{req.content_type}, when {content_type} is expected." + ) inputs = _get_inputs_from_request(req, content_type, binary_loads=binary_loads) return _validate_and_invoke_mapper(func, inputs) @@ -1216,30 +1217,65 @@ def decorator(func): return decorator +def _request_body(request) -> bytes: + """The raw body of a Bottle or Flask request (told apart by ``get_data``).""" + if hasattr(request, "get_data"): # Flask (werkzeug) + return request.get_data() + return request.body.read() + + +def _request_files(request) -> dict: + """``{field name: bytes}`` of the uploaded files of a Bottle or Flask request.""" + 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()} + + +def _loads_mapping(data: bytes, what: str) -> dict: + """Decode a JSON object from ``data``; an ``InputError`` (a 400) when it is empty, + not JSON or not an object. + + >>> _loads_mapping(b'{"x": 1}', "body") + {'x': 1} + >>> _loads_mapping(b'', "body") + Traceback (most recent call last): + ... + i2.errors.InputError: The request body is empty; expected a JSON object. + """ + if not data or not data.strip(): + raise InputError(f"The request {what} is empty; expected a JSON object.") + try: + inputs = json.loads(data) # bytes: json detects UTF-8/BOM, UTF-16, UTF-32 + except ValueError as error: # includes UnicodeDecodeError and JSONDecodeError + raise InputError(f"The request {what} is not valid JSON: {error}") from error + if not isinstance(inputs, Mapping): + raise InputError( + f"The request {what} must be a JSON object, not a {type(inputs).__name__}." + ) + return inputs + + def _get_inputs_from_request(request, content_type, *, binary_loads=None): defaults = getattr(request, "defaults", {}) if request.method == "POST": - if content_type == JSON_CONTENT_TYPE: - inputs = request.json - elif content_type == RAW_CONTENT_TYPE: - data = request.body.read().decode("utf-8") - inputs = json.loads(data) + if content_type in (JSON_CONTENT_TYPE, RAW_CONTENT_TYPE): + inputs = _loads_mapping(_request_body(request), "body") elif content_type == BINARY_CONTENT_TYPE: if binary_loads is None: raise TypeError( "No decoder given for a binary request body; see handle_binary_req." ) - inputs = binary_loads(request.body.read()) + inputs = binary_loads(_request_body(request)) if not isinstance(inputs, Mapping): raise TypeError( f"The binary request body decoded to a {type(inputs).__name__}, " "not a mapping of inputs." ) elif content_type == FORM_CONTENT_TYPE: - fields = json.loads( - request.files.pop("__fields").file.read().decode("utf-8") - ) - binaries = {k: v.file.read() for k, v in request.files.items()} + 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") inputs = dict(fields, **binaries) return dict(defaults, **inputs) else: diff --git a/py2http/default_configs.py b/py2http/default_configs.py index 2f0635d..f95bd89 100644 --- a/py2http/default_configs.py +++ b/py2http/default_configs.py @@ -17,6 +17,7 @@ import json import logging import os +from functools import wraps from i2.errors import ( AuthorizationError, @@ -66,6 +67,34 @@ def flask_json_output_mapper(output, **inputs): flask_json_output_mapper.content_type = JSON_CONTENT_TYPE +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. + + >>> from py2http.decorators import send_raw_resp # (needs flask) + >>> flask_response_mapper(send_raw_resp(lambda o: o))(b"x").content_type # doctest: +SKIP + 'text/plain' + """ + content_type = getattr(output_mapper, "content_type", None) + 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 + + return Response(result, content_type=content_type) + return result + + return mapper + + def bottle_output_mapper(output, **inputs): response.content_type = JSON_CONTENT_TYPE return json.dumps(output, cls=JsonRespEncoder) @@ -205,6 +234,8 @@ def framework_defaults(framework, *, error_handler, output_mapper): 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) + if framework == FLASK: + output_mapper = flask_response_mapper(output_mapper) return error_handler, output_mapper diff --git a/py2http/service.py b/py2http/service.py index 77a2f7a..8124945 100644 --- a/py2http/service.py +++ b/py2http/service.py @@ -317,9 +317,9 @@ def mk_routes_and_openapi_specs(funcs, **configs): 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 + """A Flask app serving ``funcs``. The JSON, raw, binary and form input mappers and + the ``send_*_resp`` output mappers work as under Bottle; ``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 diff --git a/py2http/tests/test_flask_requests.py b/py2http/tests/test_flask_requests.py new file mode 100644 index 0000000..4da6f0d --- /dev/null +++ b/py2http/tests/test_flask_requests.py @@ -0,0 +1,136 @@ +"""Under ``framework='flask'`` the non-JSON input mappers read Flask's request, the +``send_*_resp`` mappers answer their content type, and a bad body is a 400.""" + +import json +import pickle + +import pytest + +from py2http import mk_app +from py2http.constants import ( + BINARY_CONTENT_TYPE, + FORM_CONTENT_TYPE, + JSON_CONTENT_TYPE, + RAW_CONTENT_TYPE, +) +from py2http.decorators import ( + handle_binary_req, + handle_form_req, + handle_raw_req, + send_binary_resp, + send_json_resp, + send_raw_resp, + unsafe_pickle_loads, +) + +pytest.importorskip("flask") + + +def _client(func, **configs): + return mk_app([func], framework="flask", **configs).test_client() + + +def add(x: int, y: int = 1): + return x + y + + +def upload(name: str, data: bytes): + return f"{name}:{len(data)}" + + +def test_raw_req(): + r = _client(add, input_mapper=handle_raw_req(lambda x, y=1: dict(x=x, y=y))).post( + "/add", data=json.dumps({"x": 1}), content_type=RAW_CONTENT_TYPE + ) + assert (r.status_code, r.get_json()) == (200, 2) + + +def test_binary_req(): + mapper = handle_binary_req(lambda x, y=1: dict(x=x, y=y), loads=unsafe_pickle_loads) + r = _client(add, input_mapper=mapper).post( + "/add", data=pickle.dumps({"x": 3, "y": 4}), content_type=BINARY_CONTENT_TYPE + ) + assert (r.status_code, r.get_json()) == (200, 7) + + +def test_form_req(): + import io + + mapper = handle_form_req(lambda name, data: dict(name=name, data=data)) + r = _client(upload, input_mapper=mapper).post( + "/upload", + data={ + "__fields": (io.BytesIO(b'{"name": "n"}'), "__fields"), + "data": (io.BytesIO(b"abc"), "data"), + }, + content_type="multipart/form-data", + ) + assert (r.status_code, r.get_json()) == (200, "n:3") + + +@pytest.mark.parametrize( + "decorator, content_type, body, expected", + [ + (send_raw_resp, RAW_CONTENT_TYPE, "hi", b"hi"), + (send_binary_resp, BINARY_CONTENT_TYPE, "hi", pickle.dumps("hi")), + (send_json_resp, JSON_CONTENT_TYPE, {"a": 1}, b'{"a": 1}'), + ], +) +def test_send_resp_content_type(decorator, content_type, body, expected): + mapper = decorator(lambda output, **_: body) + r = _client(add, output_mapper=mapper).post("/add", json={"x": 1}) + assert r.status_code == 200 + assert r.content_type.split(";")[0] == content_type + assert r.data == expected + + +@pytest.mark.parametrize( + "body, content_type", + [ + (b"", JSON_CONTENT_TYPE), + (b" ", JSON_CONTENT_TYPE), + (b"{not json", JSON_CONTENT_TYPE), + (b"[1, 2]", JSON_CONTENT_TYPE), + (b"\xff\xfe", JSON_CONTENT_TYPE), + (b'{"x": 1}', "text/plain"), + (b'{"x": 1}', None), + ], +) +def test_bad_bodies_are_400(body, content_type): + kwargs = {"content_type": content_type} if content_type else {} + r = _client(add).post("/add", data=body, **kwargs) + assert r.status_code == 400 + assert "Traceback" not in r.get_data(as_text=True) + assert r.get_json()["error"] + + +def test_bottle_bad_body_is_400(): + from wsgiref.util import setup_testing_defaults + import io + + app = mk_app([add]) + environ = {} + setup_testing_defaults(environ) + environ.update( + REQUEST_METHOD="POST", + PATH_INFO="/add", + CONTENT_TYPE=JSON_CONTENT_TYPE, + CONTENT_LENGTH="0", + ) + environ["wsgi.input"] = io.BytesIO(b"") + out = {} + app(environ, lambda status, headers, *a: out.setdefault("status", status)) + assert out["status"].startswith("400") + + +@pytest.mark.parametrize( + "body, content_type", + [ + (b"\xef\xbb\xbf" + b'{"x": 2}', JSON_CONTENT_TYPE), # UTF-8 with a BOM + ('{"x": 2}'.encode("utf-16"), JSON_CONTENT_TYPE), + (b'{"x": 2}', "Application/JSON"), # media types are case-insensitive + ], +) +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)