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
66 changes: 51 additions & 15 deletions py2http/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down
31 changes: 31 additions & 0 deletions py2http/default_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import json
import logging
import os
from functools import wraps

from i2.errors import (
AuthorizationError,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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


Expand Down
6 changes: 3 additions & 3 deletions py2http/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
136 changes: 136 additions & 0 deletions py2http/tests/test_flask_requests.py
Original file line number Diff line number Diff line change
@@ -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)
Loading