Skip to content

Commit

Permalink
fix: Handling of API operations that contain reserved characters in t…
Browse files Browse the repository at this point in the history
…heir paths

Ref: #992
  • Loading branch information
Stranger6667 committed Jan 3, 2021
1 parent f1566f7 commit b8f42df
Show file tree
Hide file tree
Showing 11 changed files with 55 additions and 5 deletions.
1 change: 1 addition & 0 deletions CONTRIBUTING.rst
Expand Up @@ -123,6 +123,7 @@ By default, the server will generate an API schema with the following endpoints:
- ``POST /api/users/`` (``create_user``) - creates a user and stores it in memory. Provides Open API links to the endpoints below
- ``GET /api/users/{user_id}`` (``get_user``) - returns a user stored in memory
- ``PATCH /api/users/{user_id}`` (``update_user``) - updates a user stored in memory
- ``GET /api/foo:bar`` (``reserved``) - contains ``:`` in its path

You can find the complete schema at ``http://127.0.0.1:8081/schema.yaml`` (replace 8081 with the port you chose in the start server command).

Expand Down
2 changes: 2 additions & 0 deletions docs/changelog.rst
Expand Up @@ -43,6 +43,7 @@ Changelog
- ``TypeError`` on nullable parameters during Open API specific serialization. `#980`_
- Invalid types in ``x-examples``. `#982`_
- CLI crash on schemas with operation names longer than the current terminal width. `#990`_
- Handling of API operations that contain reserved characters in their paths. `#992`_

**Performance**

Expand Down Expand Up @@ -1632,6 +1633,7 @@ Deprecated
.. _0.3.0: https://github.com/schemathesis/schemathesis/compare/v0.2.0...v0.3.0
.. _0.2.0: https://github.com/schemathesis/schemathesis/compare/v0.1.0...v0.2.0

.. _#992: https://github.com/schemathesis/schemathesis/issues/992
.. _#990: https://github.com/schemathesis/schemathesis/issues/990
.. _#987: https://github.com/schemathesis/schemathesis/issues/987
.. _#982: https://github.com/schemathesis/schemathesis/issues/982
Expand Down
4 changes: 2 additions & 2 deletions src/schemathesis/models.py
Expand Up @@ -23,7 +23,7 @@
Union,
cast,
)
from urllib.parse import urljoin, urlsplit, urlunsplit
from urllib.parse import quote, unquote, urljoin, urlsplit, urlunsplit

import attr
import curlify
Expand Down Expand Up @@ -244,7 +244,7 @@ def as_requests_kwargs(
final_headers["Content-Type"] = self.media_type
base_url = self._get_base_url(base_url)
formatted_path = self.formatted_path.lstrip("/") # pragma: no mutate
url = urljoin(base_url + "/", formatted_path)
url = unquote(urljoin(base_url + "/", quote(formatted_path)))
extra: Dict[str, Any]
serializer = self._get_serializer()
if serializer is not None and self.body is not NOT_SET:
Expand Down
4 changes: 2 additions & 2 deletions src/schemathesis/schemas.py
Expand Up @@ -9,7 +9,7 @@
from collections.abc import Mapping
from difflib import get_close_matches
from typing import Any, Callable, Dict, Generator, Iterable, Iterator, List, Optional, Sequence, Tuple, Type, Union
from urllib.parse import urljoin, urlsplit, urlunsplit
from urllib.parse import quote, unquote, urljoin, urlsplit, urlunsplit

import attr
import hypothesis
Expand Down Expand Up @@ -76,7 +76,7 @@ def verbose_name(self) -> str:

def get_full_path(self, path: str) -> str:
"""Compute full path for the given path."""
return urljoin(self.base_path, path.lstrip("/")) # pragma: no mutate
return unquote(urljoin(self.base_path, quote(path.lstrip("/")))) # pragma: no mutate

@property
def base_path(self) -> str:
Expand Down
1 change: 1 addition & 0 deletions test/apps/_aiohttp/handlers.py
Expand Up @@ -227,6 +227,7 @@ async def update_user(request: web.Request) -> web.Response:

get_payload = payload
path_variable = success
reserved = success
invalid = success
invalid_path_parameter = success
missing_path_parameter = success
4 changes: 4 additions & 0 deletions test/apps/_flask/__init__.py
Expand Up @@ -54,6 +54,10 @@ def success():
1 / 0
return jsonify({"success": True})

@app.route("/api/foo:bar", methods=["GET"])
def reserved():
return jsonify({"success": True})

@app.route("/api/recursive", methods=["GET"])
def recursive():
return jsonify({"children": [{"children": [{"children": []}]}]})
Expand Down
1 change: 1 addition & 0 deletions test/apps/utils.py
Expand Up @@ -31,6 +31,7 @@ class Endpoint(Enum):
invalid_path_parameter = ("GET", "/api/invalid_path_parameter/{id}")
missing_path_parameter = ("GET", "/api/missing_path_parameter/{id}")
headers = ("GET", "/api/headers")
reserved = ("GET", "/api/foo:bar")

create_user = ("POST", "/api/users/")
get_user = ("GET", "/api/users/{user_id}")
Expand Down
2 changes: 1 addition & 1 deletion test/cli/test_cassettes.py
Expand Up @@ -204,7 +204,7 @@ async def test_replay(openapi_version, cli, schema_url, app, reset_app, cassette
url = urlunparse(
(parsed.scheme, parsed.netloc, encoded_path, parsed.params, encoded_query, parsed.fragment)
)
assert url == serialized["uri"], request.url
assert unquote_plus(url) == unquote_plus(serialized["uri"]), request.url
content = await request.read()
assert content == base64.b64decode(serialized["body"]["base64_string"])
compare_headers(request, serialized["headers"])
Expand Down
17 changes: 17 additions & 0 deletions test/cli/test_commands.py
Expand Up @@ -1710,3 +1710,20 @@ def test_long_operation_output(testdir, empty_open_api_3_schema):
assert result.ret == ExitCode.OK
assert "GET /aaaaaaaaaa . [ 50%]" in result.outlines
assert "GET /aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa[...] . [100%]" in result.outlines


def test_reserved_characters_in_operation_name(testdir, empty_open_api_3_schema):
# See GH-992
# When an API operation name contains `:`
empty_open_api_3_schema["paths"] = {
f"/foo:bar": {
"get": {
"responses": {"200": {"description": "OK"}},
}
},
}
schema_file = testdir.makefile(".yaml", schema=yaml.dump(empty_open_api_3_schema))
result = testdir.run("schemathesis", "run", str(schema_file), "--dry-run")
# Then this operation name should be displayed with the leading `/`
assert result.ret == ExitCode.OK
assert "GET /foo:bar . [100%]" in result.outlines
15 changes: 15 additions & 0 deletions test/runner/test_runner.py
Expand Up @@ -776,3 +776,18 @@ def check(response, case):
execute("/openapi.json", app=loadable_fastapi_app, checks=(check,), dry_run=True)
# Then no requests should be sent & no responses checked
assert not called


@pytest.mark.endpoints("reserved")
def test_reserved_characters_in_operation_name(args):
# See GH-992

def check(response, case):
assert response.status_code == 200

# When there is `:` in the API operation path
app, kwargs = args
result = execute(checks=(check,), **kwargs)
# Then it should be reachable
assert not result.has_errors
assert not result.has_failures
9 changes: 9 additions & 0 deletions test/test_models.py
Expand Up @@ -55,6 +55,15 @@ def test_as_requests_kwargs(override, server, base_url, swagger_20, converter):
assert response.json() == {"success": True}


def test_reserved_characters_in_operation_name(swagger_20):
# See GH-992
# When an API operation name contains `:`
endpoint = Endpoint("/foo:bar", "GET", {}, swagger_20)
case = endpoint.make_case()
# Then it should not be truncated during API call
assert case.as_requests_kwargs("/")["url"] == "/foo:bar"


@pytest.mark.parametrize(
"headers, expected",
(
Expand Down

0 comments on commit b8f42df

Please sign in to comment.