Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨Airbyte CDK: add POST method to HttpMocker #34001

Merged
merged 11 commits into from
Jan 10, 2024
33 changes: 26 additions & 7 deletions airbyte-cdk/python/airbyte_cdk/test/mock_http/mocker.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,25 @@ def get(self, request: HttpRequest, responses: Union[HttpResponse, List[HttpResp
response_list=[{"text": response.body, "status_code": response.status_code} for response in responses],
)

def _matches_wrapper(self, matcher: HttpRequestMatcher) -> Callable[[requests_mock.request._RequestObjectProxy], bool]:
def post(self, request: HttpRequest, responses: Union[HttpResponse, List[HttpResponse]]) -> None:
maxi297 marked this conversation as resolved.
Show resolved Hide resolved
if isinstance(responses, HttpResponse):
maxi297 marked this conversation as resolved.
Show resolved Hide resolved
responses = [responses]

matcher = HttpRequestMatcher(request, len(responses))
self._matchers.append(matcher)
self._mocker.post(
requests_mock.ANY,
additional_matcher=self._matches_wrapper(matcher),
response_list=[{"text": response.body, "status_code": response.status_code} for response in responses],
)

@staticmethod
def _matches_wrapper(matcher: HttpRequestMatcher) -> Callable[[requests_mock.request._RequestObjectProxy], bool]:
def matches(requests_mock_request: requests_mock.request._RequestObjectProxy) -> bool:
# query_params are provided as part of `requests_mock_request.url`
http_request = HttpRequest(requests_mock_request.url, query_params={}, headers=requests_mock_request.headers)
http_request = HttpRequest(
requests_mock_request.url, query_params={}, headers=requests_mock_request.headers, body=requests_mock_request.text
)
return matcher.matches(http_request)

return matches
Expand All @@ -70,7 +85,8 @@ def assert_number_of_calls(self, request: HttpRequest, number_of_calls: int) ->

assert corresponding_matchers[0].actual_number_of_matches == number_of_calls

def __call__(self, f): # type: ignore # trying to type that using callables provides the error `incompatible with return type "_F" in supertype "ContextDecorator"`
# trying to type that using callables provides the error `incompatible with return type "_F" in supertype "ContextDecorator"`
def __call__(self, f): # type: ignore
@functools.wraps(f)
def wrapper(*args, **kwargs): # type: ignore # this is a very generic wrapper that does not need to be typed
with self:
Expand All @@ -82,18 +98,21 @@ def wrapper(*args, **kwargs): # type: ignore # this is a very generic wrapper
except requests_mock.NoMockAddress as no_mock_exception:
matchers_as_string = "\n\t".join(map(lambda matcher: str(matcher.request), self._matchers))
raise ValueError(
f"No matcher matches {no_mock_exception.args[0]} with headers `{no_mock_exception.request.headers}`. Matchers currently configured are:\n\t{matchers_as_string}"
f"No matcher matches {no_mock_exception.args[0]} with headers `{no_mock_exception.request.headers}` "
f"and body `{no_mock_exception.request.body}`. "
f"Matchers currently configured are:\n\t{matchers_as_string}."
) from no_mock_exception
except AssertionError as test_assertion:
assertion_error = test_assertion

# We validate the matchers before raising the assertion error because we want to show the tester if a HTTP request wasn't
# We validate the matchers before raising the assertion error because we want to show the tester if an HTTP request wasn't
# mocked correctly
try:
self._validate_all_matchers_called()
except ValueError as http_mocker_exception:
# This seems useless as it catches ValueError and raises ValueError but without this, the prevaling error message in
# the output is the function call that failed the assertion, whereas raising `ValueError(http_mocker_exception)` like we do here provides additional context for the exception.
# This seems useless as it catches ValueError and raises ValueError but without this, the prevailing error message in
# the output is the function call that failed the assertion, whereas raising `ValueError(http_mocker_exception)`
# like we do here provides additional context for the exception.
raise ValueError(http_mocker_exception) from None
if assertion_error:
raise assertion_error
Expand Down
12 changes: 8 additions & 4 deletions airbyte-cdk/python/airbyte_cdk/test/mock_http/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def __init__(
url: str,
query_params: Optional[Union[str, Mapping[str, Union[str, List[str]]]]] = None,
headers: Optional[Mapping[str, str]] = None,
body: Optional[Union[str, Mapping[str, Any]]] = None,
) -> None:
self._parsed_url = urlparse(url)
self._query_params = query_params
Expand All @@ -25,8 +26,10 @@ def __init__(
raise ValueError("If query params are provided as part of the url, `query_params` should be empty")

self._headers = headers or {}
self._body = body or {}

def _encode_qs(self, query_params: Union[str, Mapping[str, Union[str, List[str]]]]) -> str:
@staticmethod
def _encode_qs(query_params: Union[str, Mapping[str, Union[str, List[str]]]]) -> str:
if isinstance(query_params, str):
return query_params
return urlencode(query_params, doseq=True)
Expand All @@ -41,15 +44,16 @@ def matches(self, other: Any) -> bool:
and self._parsed_url.hostname == other._parsed_url.hostname
and self._parsed_url.path == other._parsed_url.path
and (
ANY_QUERY_PARAMS in [self._query_params, other._query_params]
ANY_QUERY_PARAMS in (self._query_params, other._query_params)
or parse_qs(self._parsed_url.query) == parse_qs(other._parsed_url.query)
)
and _is_subdict(other._headers, self._headers)
and other._body == self._body
maxi297 marked this conversation as resolved.
Show resolved Hide resolved
)
return False

def __str__(self) -> str:
return f"{self._parsed_url} with headers {self._headers})"
return f"{self._parsed_url} with headers {self._headers} and body {self._body})"

def __repr__(self) -> str:
return f"HttpRequest(request={self._parsed_url}, headers={self._headers})"
return f"HttpRequest(request={self._parsed_url}, headers={self._headers}, body={self._body})"
50 changes: 39 additions & 11 deletions airbyte-cdk/python/unit_tests/test/mock_http/test_mocker.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,51 +10,79 @@
# see https://github.com/psf/requests/blob/0b4d494192de489701d3a2e32acef8fb5d3f042e/src/requests/models.py#L424-L429
_A_URL = "http://test.com/"
_ANOTHER_URL = "http://another-test.com/"
_A_BODY = "a body"
_ANOTHER_BODY = "another body"
_A_RESPONSE_BODY = "a body"
_ANOTHER_RESPONSE_BODY = "another body"
_A_RESPONSE = HttpResponse("any response")
_SOME_QUERY_PARAMS = {"q1": "query value"}
_SOME_HEADERS = {"h1": "header value"}
_SOME_REQUEST_BODY = "{'first_field': 'first_value', 'second_field': 2}"


class HttpMockerTest(TestCase):
@HttpMocker()
def test_given_request_match_when_decorate_then_return_response(self, http_mocker):
def test_given_get_request_match_when_decorate_then_return_response(self, http_mocker):
http_mocker.get(
HttpRequest(_A_URL, _SOME_QUERY_PARAMS, _SOME_HEADERS),
HttpResponse(_A_BODY, 474),
HttpResponse(_A_RESPONSE_BODY, 474),
)

response = requests.get(_A_URL, params=_SOME_QUERY_PARAMS, headers=_SOME_HEADERS)

assert response.text == _A_BODY
assert response.text == _A_RESPONSE_BODY
assert response.status_code == 474

@HttpMocker()
def test_given_multiple_responses_when_decorate_then_return_response(self, http_mocker):
def test_given_post_request_match_when_decorate_then_return_response(self, http_mocker):
http_mocker.post(
HttpRequest(_A_URL, _SOME_QUERY_PARAMS, _SOME_HEADERS, _SOME_REQUEST_BODY),
HttpResponse(_A_RESPONSE_BODY, 474),
)

response = requests.post(_A_URL, params=_SOME_QUERY_PARAMS, headers=_SOME_HEADERS, data=_SOME_REQUEST_BODY)

assert response.text == _A_RESPONSE_BODY
assert response.status_code == 474

@HttpMocker()
def test_given_multiple_responses_when_decorate_get_request_then_return_response(self, http_mocker):
http_mocker.get(
HttpRequest(_A_URL, _SOME_QUERY_PARAMS, _SOME_HEADERS),
[HttpResponse(_A_BODY, 1), HttpResponse(_ANOTHER_BODY, 2)],
[HttpResponse(_A_RESPONSE_BODY, 1), HttpResponse(_ANOTHER_RESPONSE_BODY, 2)],
)

first_response = requests.get(_A_URL, params=_SOME_QUERY_PARAMS, headers=_SOME_HEADERS)
second_response = requests.get(_A_URL, params=_SOME_QUERY_PARAMS, headers=_SOME_HEADERS)

assert first_response.text == _A_BODY
assert first_response.text == _A_RESPONSE_BODY
assert first_response.status_code == 1
assert second_response.text == _ANOTHER_RESPONSE_BODY
assert second_response.status_code == 2

@HttpMocker()
def test_given_multiple_responses_when_decorate_post_request_then_return_response(self, http_mocker):
http_mocker.post(
HttpRequest(_A_URL, _SOME_QUERY_PARAMS, _SOME_HEADERS, _SOME_REQUEST_BODY),
[HttpResponse(_A_RESPONSE_BODY, 1), HttpResponse(_ANOTHER_RESPONSE_BODY, 2)],
)

first_response = requests.post(_A_URL, params=_SOME_QUERY_PARAMS, headers=_SOME_HEADERS, data=_SOME_REQUEST_BODY)
second_response = requests.post(_A_URL, params=_SOME_QUERY_PARAMS, headers=_SOME_HEADERS, data=_SOME_REQUEST_BODY)

assert first_response.text == _A_RESPONSE_BODY
assert first_response.status_code == 1
assert second_response.text == _ANOTHER_BODY
assert second_response.text == _ANOTHER_RESPONSE_BODY
assert second_response.status_code == 2

@HttpMocker()
def test_given_more_requests_than_responses_when_decorate_then_raise_error(self, http_mocker):
http_mocker.get(
HttpRequest(_A_URL, _SOME_QUERY_PARAMS, _SOME_HEADERS),
[HttpResponse(_A_BODY, 1), HttpResponse(_ANOTHER_BODY, 2)],
[HttpResponse(_A_RESPONSE_BODY, 1), HttpResponse(_ANOTHER_RESPONSE_BODY, 2)],
)

last_response = [requests.get(_A_URL, params=_SOME_QUERY_PARAMS, headers=_SOME_HEADERS) for _ in range(10)][-1]

assert last_response.text == _ANOTHER_BODY
assert last_response.text == _ANOTHER_RESPONSE_BODY
assert last_response.status_code == 2

@HttpMocker()
Expand Down
10 changes: 10 additions & 0 deletions airbyte-cdk/python/unit_tests/test/mock_http/test_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ def test_given_headers_value_does_not_match_differs_when_matches_then_return_fal
request_received = HttpRequest("mock://test.com/path", headers={"first_header": "value does not match"})
assert not request_received.matches(request_to_match)

def test_given_same_body_value_when_matches_then_return_true(self):
request_to_match = HttpRequest("mock://test.com/path", body={"first_field": "first_value", "second_field": 2})
request_received = HttpRequest("mock://test.com/path", body={"first_field": "first_value", "second_field": 2})
assert request_received.matches(request_to_match)

def test_given_body_value_differs_when_matches_then_return_false(self):
request_to_match = HttpRequest("mock://test.com/path", body={"first_field": "first_value"})
request_received = HttpRequest("mock://test.com/path", body={"first_field": "value does not match"})
assert not request_received.matches(request_to_match)

def test_given_any_matcher_for_query_param_when_matches_then_return_true(self):
request_to_match = HttpRequest("mock://test.com/path", {"a_query_param": "q1"})
request_received = HttpRequest("mock://test.com/path", ANY_QUERY_PARAMS)
Expand Down
Loading