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
45 changes: 44 additions & 1 deletion http2py/py2request.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ def mk_request_function(

original_spec = method_spec
method_spec = method_spec.copy() # make a copy
if "url_template" in method_spec:
check_url_template(
method_spec["url_template"],
query_arg_names=_ensure_list(method_spec.get("query_arg_names", [])),
)
method_spec["input_trans"] = method_spec.get("input_trans", None) or {}

request_kwargs = method_spec.get("request_kwargs", {}).copy()
Expand Down Expand Up @@ -285,7 +290,7 @@ def request_func(*args, **kwargs):
_request_kwargs["data"] = json.dumps(json_kwargs)
elif content_type == BINARY_CONTENT_TYPE:
_request_kwargs["data"] = binary_dumps(kwargs)
elif content_type == FORM_CONTENT_TYPE:
if content_type == FORM_CONTENT_TYPE: # (it used to be sent with GET only)
fields = json.dumps(json_kwargs).encode("utf-8")
binaries = {k: (f"{k}.bin", v) for k, v in binary_kwargs.items()}
files_data = dict(binaries, __fields=fields)
Expand Down Expand Up @@ -502,6 +507,44 @@ def _difference_conserving_order(a, b):
return [x for x in a if x not in b]


def _reads_into_arguments(field_name: str) -> bool:
"""Whether a ``str.format`` field looks up an attribute or item of an argument;
any other name (``user-id``, ``0``...) only selects an argument."""
return "." in field_name or "[" in field_name


def check_url_template(url_template: str, *, query_arg_names=()) -> str:
"""Return ``url_template`` if no placeholder in it, nor any of the
``query_arg_names`` (appended as ``?name={name}`` when calling), reads into an
argument.

A template is filled with ``str.format(**arguments)``, and it may come from a spec
fetched from a server. ``{a.__class__}`` or ``{a[0]}`` would read attributes or
items of the caller's arguments into the URL, so they are refused. Other names,
such as OpenAPI's hyphenated ``{user-id}``, are fine.

>>> check_url_template('http://h/u/{user-id}?q={q}')
'http://h/u/{user-id}?q={q}'
>>> check_url_template('http://h/{a.__init__.__globals__}')
Traceback (most recent call last):
...
ValueError: URL template placeholder '{a.__init__.__globals__}' reads into an argument
"""
fields = [
(field_name, format_spec)
for _, field_name, format_spec, _ in str_formatter.parse(url_template)
if field_name is not None
]
fields += [(name, "") for name in query_arg_names]
for field_name, format_spec in fields:
if _reads_into_arguments(field_name) or "{" in (format_spec or ""):
raise ValueError(
f"URL template placeholder {'{' + field_name + '}'!r} reads into an "
"argument"
)
return url_template


def extract_format_names(string_template):
"""
Extracts the names of the fields in a string template, returning a list of
Expand Down
166 changes: 166 additions & 0 deletions tests/test_response_decoding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""How responses are decoded (``http2py.decorators``) and how a client's requests are
built from a spec it may have fetched from a server (``mk_request_function``)."""

import pickle
import warnings

import pytest

from http2py.decorators import (
BinaryResponseNotDecoded,
check_loads,
handle_binary_resp,
unsafe_pickle_loads,
)
from http2py.py2request import mk_request_function


class Resp:
def __init__(self, content=b"", *, status_code=200, text="", reason="OK"):
self.content, self.status_code, self.text, self.reason = (
content,
status_code,
text,
reason,
)


def _identity(x):
return x


def test_binary_response_needs_an_explicit_decoder():
with pytest.raises(BinaryResponseNotDecoded):
handle_binary_resp(_identity)(Resp(pickle.dumps(1)))
assert handle_binary_resp(_identity, loads=bytes)(Resp(b"raw")) == b"raw"
trusted = handle_binary_resp(_identity, loads=unsafe_pickle_loads)
assert trusted(Resp(pickle.dumps({"a": 1}))) == {"a": 1}


def test_check_loads():
check_loads(None)
check_loads(bytes)
with pytest.raises(TypeError):
check_loads("pickle.loads")
with pytest.warns(UserWarning, match="unsafe_pickle_loads"):
check_loads(pickle.loads)
with warnings.catch_warnings():
warnings.simplefilter("error")
check_loads(unsafe_pickle_loads) # the explicit opt-in does not warn


def test_non_200_binary_response_raises_the_mapped_error():
from i2.errors import NotFoundError

with pytest.raises(NotFoundError):
handle_binary_resp(_identity, loads=bytes)(Resp(status_code=404, text="no"))


def _captured_request(method_spec, **call_kwargs):
"""The (method, url, request kwargs) a request function would dispatch."""
calls = []

def dispatch(method, url, **kwargs):
calls.append((method, url, kwargs))
return Resp(b"{}")

func = mk_request_function(method_spec, dispatch=dispatch)
func(None, **call_kwargs)
return calls[0]


def test_binary_request_bodies_use_binary_dumps():
spec = {
"method": "POST",
"url_template": "http://h/f",
"body_arg_names": ["x"],
"content_type": "application/octet-stream",
"output_trans": _identity,
}
_, _, kwargs = _captured_request(spec, x=1)
assert pickle.loads(kwargs["data"]) == {"x": 1}
calls = []
func = mk_request_function(
spec,
dispatch=lambda m, u, **kw: calls.append(kw) or Resp(),
binary_dumps=lambda d: b"custom",
)
func(None, x=1)
assert calls[0]["data"] == b"custom"


def test_path_args_are_url_encoded():
spec = {
"method": "GET",
"url_template": "http://h/u/{uid}",
"path_arg_names": ["uid"],
"output_trans": _identity,
}
_, url, _ = _captured_request(spec, uid="a/b c")
assert url == "http://h/u/a%2Fb%20c"


@pytest.mark.parametrize(
"template",
[
"http://h/{x.__class__}",
"http://h/{x[0]}",
"http://h/{x:{x.__class__}}",
"http://h/{x!r:{y}}",
],
)
def test_url_templates_may_only_name_arguments(template):
"""A spec served by a remote must not read attributes of the caller's
arguments into the URL."""
spec = {"method": "GET", "url_template": template, "path_arg_names": ["x"]}
with pytest.raises(ValueError, match="reads into an argument"):
mk_request_function(spec)


def test_query_arg_names_may_only_name_arguments():
spec = {
"method": "GET",
"url_template": "http://h/x",
"query_arg_names": ["obj", "obj.token"],
}
with pytest.raises(ValueError, match="reads into an argument"):
mk_request_function(spec)


def test_hyphenated_placeholders_still_work():
spec = {"method": "GET", "url_template": "http://h/u/{user-id}"}
calls = []
func = mk_request_function(
spec, dispatch=lambda m, u, **kw: calls.append(u) or Resp(b"")
)
func(None, **{"user-id": "42"})
assert calls == ["http://h/u/42"]


def test_spec_with_attribute_placeholder_is_refused_by_the_client():
import copy
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
from spec_fixture import SPEC

from http2py import HttpClient

spec = copy.deepcopy(SPEC)
spec["paths"]["/x/{uid.__class__.__mro__}"] = spec["paths"].pop("/u/{uid}/p/{pid}")
with pytest.raises(ValueError, match="reads into an argument"):
HttpClient(spec, session_state={"session": object(), "refresh_inputs": {}})


@pytest.mark.parametrize("method", ["GET", "POST"])
def test_form_requests_send_their_fields_whatever_the_method(method):
spec = {
"method": method,
"url_template": "http://h/f",
"body_arg_names": ["x", "blob"],
"content_type": "multipart/form-data",
"output_trans": _identity,
}
_, _, kwargs = _captured_request(spec, x=1, blob=b"\x00")
assert set(kwargs["files"]) == {"blob", "__fields"}
12 changes: 12 additions & 0 deletions tests/test_route_namespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ def test_cli_calls_the_route_not_the_client_attribute(monkeypatch):
spec = _spec_naming_plain("ensure_login")
with pytest.warns(UserWarning):
route = _client(spec).routes["ensure_login"]

class Sent(Exception):
pass

Expand Down Expand Up @@ -162,3 +163,14 @@ def test_unusable_operation_id_falls_back_to_method_and_path(operation_id):
spec = _spec_without_names()
spec["paths"]["/plain"]["get"]["operationId"] = operation_id
assert "get_plain" in _client(spec).routes


def test_a_second_route_of_the_same_name_replaces_the_first_with_a_warning():
spec = copy.deepcopy(SPEC)
other = copy.deepcopy(spec["paths"]["/plain"])
spec["paths"]["/plain2"] = other
with pytest.warns(UserWarning, match="Two routes are named"):
client = _client(spec)
name = other["get"]["x-method_name"]
assert client.routes[name].method_spec["url_template"].endswith("/plain2")
assert getattr(client, name) is client.routes[name]
Loading