diff --git a/http2py/py2request.py b/http2py/py2request.py index 2a7caf1..eb4c47e 100644 --- a/http2py/py2request.py +++ b/http2py/py2request.py @@ -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() @@ -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) @@ -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 diff --git a/tests/test_response_decoding.py b/tests/test_response_decoding.py new file mode 100644 index 0000000..5b460ee --- /dev/null +++ b/tests/test_response_decoding.py @@ -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"} diff --git a/tests/test_route_namespace.py b/tests/test_route_namespace.py index 325d9c6..c460ad0 100644 --- a/tests/test_route_namespace.py +++ b/tests/test_route_namespace.py @@ -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 @@ -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]