airbyte_cdk.test.mock_http

1from airbyte_cdk.test.mock_http.matcher import HttpRequestMatcher
2from airbyte_cdk.test.mock_http.mocker import HttpMocker
3from airbyte_cdk.test.mock_http.request import HttpRequest
4from airbyte_cdk.test.mock_http.response import HttpResponse
5
6__all__ = ["HttpMocker", "HttpRequest", "HttpRequestMatcher", "HttpResponse"]
class HttpMocker(contextlib.ContextDecorator):
 26class HttpMocker(contextlib.ContextDecorator):
 27    """
 28    WARNING 1: This implementation only works if the lib used to perform HTTP requests is `requests`.
 29
 30    WARNING 2: Given multiple requests that are not mutually exclusive, the request will match the first one. This can happen in scenarios
 31    where the same request is added twice (in which case there will always be an exception because we will never match the second
 32    request) or in a case like this:
 33    ```
 34    http_mocker.get(HttpRequest(_A_URL, headers={"less_granular": "1", "more_granular": "2"}), <...>)
 35    http_mocker.get(HttpRequest(_A_URL, headers={"less_granular": "1"}), <...>)
 36    requests.get(_A_URL, headers={"less_granular": "1", "more_granular": "2"})
 37    ```
 38    In the example above, the matcher would match the second mock as requests_mock iterate over the matcher in reverse order (see
 39    https://github.com/jamielennox/requests-mock/blob/c06f124a33f56e9f03840518e19669ba41b93202/requests_mock/adapter.py#L246) even
 40    though the request sent is a better match for the first `http_mocker.get`.
 41    """
 42
 43    def __init__(self) -> None:
 44        self._mocker = requests_mock.Mocker()
 45        self._matchers: Dict[SupportedHttpMethods, List[HttpRequestMatcher]] = defaultdict(list)
 46
 47    def __enter__(self) -> "HttpMocker":
 48        self._mocker.__enter__()
 49        return self
 50
 51    def __exit__(
 52        self,
 53        exc_type: Optional[BaseException],
 54        exc_val: Optional[BaseException],
 55        exc_tb: Optional[TracebackType],
 56    ) -> None:
 57        self._mocker.__exit__(exc_type, exc_val, exc_tb)
 58
 59    def _validate_all_matchers_called(self) -> None:
 60        for matcher in self._get_matchers():
 61            if not matcher.has_expected_match_count():
 62                raise ValueError(f"Invalid number of matches for `{matcher}`")
 63
 64    def _mock_request_method(
 65        self,
 66        method: SupportedHttpMethods,
 67        request: HttpRequest,
 68        responses: Union[HttpResponse, List[HttpResponse]],
 69    ) -> None:
 70        if isinstance(responses, HttpResponse):
 71            responses = [responses]
 72
 73        matcher = HttpRequestMatcher(request, len(responses))
 74        if matcher in self._matchers[method]:
 75            raise ValueError(f"Request {matcher.request} already mocked")
 76        self._matchers[method].append(matcher)
 77
 78        getattr(self._mocker, method)(
 79            requests_mock.ANY,
 80            additional_matcher=self._matches_wrapper(matcher),
 81            response_list=[
 82                {
 83                    self._get_body_field(response): response.body,
 84                    "status_code": response.status_code,
 85                    "headers": response.headers,
 86                }
 87                for response in responses
 88            ],
 89        )
 90
 91    @staticmethod
 92    def _get_body_field(response: HttpResponse) -> str:
 93        return "text" if isinstance(response.body, str) else "content"
 94
 95    def get(self, request: HttpRequest, responses: Union[HttpResponse, List[HttpResponse]]) -> None:
 96        self._mock_request_method(SupportedHttpMethods.GET, request, responses)
 97
 98    def patch(
 99        self, request: HttpRequest, responses: Union[HttpResponse, List[HttpResponse]]
100    ) -> None:
101        self._mock_request_method(SupportedHttpMethods.PATCH, request, responses)
102
103    def post(
104        self, request: HttpRequest, responses: Union[HttpResponse, List[HttpResponse]]
105    ) -> None:
106        self._mock_request_method(SupportedHttpMethods.POST, request, responses)
107
108    def put(self, request: HttpRequest, responses: Union[HttpResponse, List[HttpResponse]]) -> None:
109        self._mock_request_method(SupportedHttpMethods.PUT, request, responses)
110
111    def delete(
112        self, request: HttpRequest, responses: Union[HttpResponse, List[HttpResponse]]
113    ) -> None:
114        self._mock_request_method(SupportedHttpMethods.DELETE, request, responses)
115
116    @staticmethod
117    def _matches_wrapper(
118        matcher: HttpRequestMatcher,
119    ) -> Callable[[requests_mock.request._RequestObjectProxy], bool]:
120        def matches(requests_mock_request: requests_mock.request._RequestObjectProxy) -> bool:
121            # query_params are provided as part of `requests_mock_request.url`
122            http_request = HttpRequest(
123                requests_mock_request.url,
124                query_params={},
125                headers=requests_mock_request.headers,
126                body=requests_mock_request.body,
127            )
128            return matcher.matches(http_request)
129
130        return matches
131
132    def assert_number_of_calls(self, request: HttpRequest, number_of_calls: int) -> None:
133        corresponding_matchers = list(
134            filter(lambda matcher: matcher.request is request, self._get_matchers())
135        )
136        if len(corresponding_matchers) != 1:
137            raise ValueError(
138                f"Was expecting only one matcher to match the request but got `{corresponding_matchers}`"
139            )
140
141        assert corresponding_matchers[0].actual_number_of_matches == number_of_calls
142
143    # trying to type that using callables provides the error `incompatible with return type "_F" in supertype "ContextDecorator"`
144    def __call__(self, f):  # type: ignore
145        @functools.wraps(f)
146        def wrapper(*args, **kwargs):  # type: ignore  # this is a very generic wrapper that does not need to be typed
147            with self:
148                assertion_error = None
149
150                kwargs["http_mocker"] = self
151                try:
152                    result = f(*args, **kwargs)
153                except requests_mock.NoMockAddress as no_mock_exception:
154                    matchers_as_string = "\n\t".join(
155                        map(lambda matcher: str(matcher.request), self._get_matchers())
156                    )
157                    raise ValueError(
158                        f"No matcher matches {no_mock_exception.args[0]} with headers `{no_mock_exception.request.headers}` "
159                        f"and body `{no_mock_exception.request.body}`. "
160                        f"Matchers currently configured are:\n\t{matchers_as_string}."
161                    ) from no_mock_exception
162                except AssertionError as test_assertion:
163                    assertion_error = test_assertion
164
165                # We validate the matchers before raising the assertion error because we want to show the tester if an HTTP request wasn't
166                # mocked correctly
167                try:
168                    self._validate_all_matchers_called()
169                except ValueError as http_mocker_exception:
170                    # This seems useless as it catches ValueError and raises ValueError but without this, the prevailing error message in
171                    # the output is the function call that failed the assertion, whereas raising `ValueError(http_mocker_exception)`
172                    # like we do here provides additional context for the exception.
173                    raise ValueError(http_mocker_exception) from None
174                if assertion_error:
175                    raise assertion_error
176                return result
177
178        return wrapper
179
180    def _get_matchers(self) -> Iterable[HttpRequestMatcher]:
181        for matchers in self._matchers.values():
182            yield from matchers
183
184    def clear_all_matchers(self) -> None:
185        """Clears all stored matchers by resetting the _matchers list to an empty state."""
186        self._matchers = defaultdict(list)

WARNING 1: This implementation only works if the lib used to perform HTTP requests is requests.

WARNING 2: Given multiple requests that are not mutually exclusive, the request will match the first one. This can happen in scenarios where the same request is added twice (in which case there will always be an exception because we will never match the second request) or in a case like this:

http_mocker.get(HttpRequest(_A_URL, headers={"less_granular": "1", "more_granular": "2"}), <...>)
http_mocker.get(HttpRequest(_A_URL, headers={"less_granular": "1"}), <...>)
requests.get(_A_URL, headers={"less_granular": "1", "more_granular": "2"})

In the example above, the matcher would match the second mock as requests_mock iterate over the matcher in reverse order (see https://github.com/jamielennox/requests-mock/blob/c06f124a33f56e9f03840518e19669ba41b93202/requests_mock/adapter.py#L246) even though the request sent is a better match for the first http_mocker.get.

def get( self, request: HttpRequest, responses: Union[HttpResponse, List[HttpResponse]]) -> None:
95    def get(self, request: HttpRequest, responses: Union[HttpResponse, List[HttpResponse]]) -> None:
96        self._mock_request_method(SupportedHttpMethods.GET, request, responses)
def patch( self, request: HttpRequest, responses: Union[HttpResponse, List[HttpResponse]]) -> None:
 98    def patch(
 99        self, request: HttpRequest, responses: Union[HttpResponse, List[HttpResponse]]
100    ) -> None:
101        self._mock_request_method(SupportedHttpMethods.PATCH, request, responses)
def post( self, request: HttpRequest, responses: Union[HttpResponse, List[HttpResponse]]) -> None:
103    def post(
104        self, request: HttpRequest, responses: Union[HttpResponse, List[HttpResponse]]
105    ) -> None:
106        self._mock_request_method(SupportedHttpMethods.POST, request, responses)
def put( self, request: HttpRequest, responses: Union[HttpResponse, List[HttpResponse]]) -> None:
108    def put(self, request: HttpRequest, responses: Union[HttpResponse, List[HttpResponse]]) -> None:
109        self._mock_request_method(SupportedHttpMethods.PUT, request, responses)
def delete( self, request: HttpRequest, responses: Union[HttpResponse, List[HttpResponse]]) -> None:
111    def delete(
112        self, request: HttpRequest, responses: Union[HttpResponse, List[HttpResponse]]
113    ) -> None:
114        self._mock_request_method(SupportedHttpMethods.DELETE, request, responses)
def assert_number_of_calls( self, request: HttpRequest, number_of_calls: int) -> None:
132    def assert_number_of_calls(self, request: HttpRequest, number_of_calls: int) -> None:
133        corresponding_matchers = list(
134            filter(lambda matcher: matcher.request is request, self._get_matchers())
135        )
136        if len(corresponding_matchers) != 1:
137            raise ValueError(
138                f"Was expecting only one matcher to match the request but got `{corresponding_matchers}`"
139            )
140
141        assert corresponding_matchers[0].actual_number_of_matches == number_of_calls
def clear_all_matchers(self) -> None:
184    def clear_all_matchers(self) -> None:
185        """Clears all stored matchers by resetting the _matchers list to an empty state."""
186        self._matchers = defaultdict(list)

Clears all stored matchers by resetting the _matchers list to an empty state.

class HttpRequest:
 15class HttpRequest:
 16    def __init__(
 17        self,
 18        url: str,
 19        query_params: Optional[Union[str, Mapping[str, Union[str, List[str]]]]] = None,
 20        headers: Optional[Mapping[str, str]] = None,
 21        body: Optional[Union[str, bytes, Mapping[str, Any]]] = None,
 22    ) -> None:
 23        self._parsed_url = urlparse(url)
 24        self._query_params = query_params
 25        if not self._parsed_url.query and query_params:
 26            self._parsed_url = urlparse(f"{url}?{self._encode_qs(query_params)}")
 27        elif self._parsed_url.query and query_params:
 28            raise ValueError(
 29                "If query params are provided as part of the url, `query_params` should be empty"
 30            )
 31
 32        self._headers = headers or {}
 33        self._body = body
 34
 35    @staticmethod
 36    def _encode_qs(query_params: Union[str, Mapping[str, Union[str, List[str]]]]) -> str:
 37        if isinstance(query_params, str):
 38            return query_params
 39        return urlencode(query_params, doseq=True)
 40
 41    def matches(self, other: Any) -> bool:
 42        """
 43        If the body of any request is a Mapping, we compare as Mappings which means that the order is not important.
 44        If the body is a string, encoding ISO-8859-1 will be assumed
 45        Headers only need to be a subset of `other` in order to match
 46        """
 47        if isinstance(other, HttpRequest):
 48            # if `other` is a mapping, we match as an object and formatting is not considers
 49            if isinstance(self._body, Mapping) or isinstance(other._body, Mapping):
 50                body_match = self._to_mapping(self._body) == self._to_mapping(other._body)
 51            else:
 52                body_match = self._to_bytes(self._body) == self._to_bytes(other._body)
 53
 54            return (
 55                self._parsed_url.scheme == other._parsed_url.scheme
 56                and self._parsed_url.hostname == other._parsed_url.hostname
 57                and self._parsed_url.path == other._parsed_url.path
 58                and (
 59                    ANY_QUERY_PARAMS in (self._query_params, other._query_params)
 60                    or parse_qs(self._parsed_url.query) == parse_qs(other._parsed_url.query)
 61                )
 62                and _is_subdict(other._headers, self._headers)
 63                and body_match
 64            )
 65        return False
 66
 67    @staticmethod
 68    def _to_mapping(
 69        body: Optional[Union[str, bytes, Mapping[str, Any]]],
 70    ) -> Optional[Mapping[str, Any]]:
 71        if isinstance(body, Mapping):
 72            return body
 73        elif isinstance(body, bytes):
 74            return json.loads(body.decode())  # type: ignore  # assumes return type of Mapping[str, Any]
 75        elif isinstance(body, str):
 76            return json.loads(body)  # type: ignore  # assumes return type of Mapping[str, Any]
 77        return None
 78
 79    @staticmethod
 80    def _to_bytes(body: Optional[Union[str, bytes]]) -> bytes:
 81        if isinstance(body, bytes):
 82            return body
 83        elif isinstance(body, str):
 84            # `ISO-8859-1` is the default encoding used by requests
 85            return body.encode("ISO-8859-1")
 86        return b""
 87
 88    def __str__(self) -> str:
 89        return f"{self._parsed_url} with headers {self._headers} and body {self._body!r})"
 90
 91    def __repr__(self) -> str:
 92        return (
 93            f"HttpRequest(request={self._parsed_url}, headers={self._headers}, body={self._body!r})"
 94        )
 95
 96    def __eq__(self, other: Any) -> bool:
 97        if isinstance(other, HttpRequest):
 98            return (
 99                self._parsed_url == other._parsed_url
100                and self._query_params == other._query_params
101                and self._headers == other._headers
102                and self._body == other._body
103            )
104        return False
HttpRequest( url: str, query_params: Union[str, Mapping[str, Union[str, List[str]]], NoneType] = None, headers: Optional[Mapping[str, str]] = None, body: Union[str, bytes, Mapping[str, Any], NoneType] = None)
16    def __init__(
17        self,
18        url: str,
19        query_params: Optional[Union[str, Mapping[str, Union[str, List[str]]]]] = None,
20        headers: Optional[Mapping[str, str]] = None,
21        body: Optional[Union[str, bytes, Mapping[str, Any]]] = None,
22    ) -> None:
23        self._parsed_url = urlparse(url)
24        self._query_params = query_params
25        if not self._parsed_url.query and query_params:
26            self._parsed_url = urlparse(f"{url}?{self._encode_qs(query_params)}")
27        elif self._parsed_url.query and query_params:
28            raise ValueError(
29                "If query params are provided as part of the url, `query_params` should be empty"
30            )
31
32        self._headers = headers or {}
33        self._body = body
def matches(self, other: Any) -> bool:
41    def matches(self, other: Any) -> bool:
42        """
43        If the body of any request is a Mapping, we compare as Mappings which means that the order is not important.
44        If the body is a string, encoding ISO-8859-1 will be assumed
45        Headers only need to be a subset of `other` in order to match
46        """
47        if isinstance(other, HttpRequest):
48            # if `other` is a mapping, we match as an object and formatting is not considers
49            if isinstance(self._body, Mapping) or isinstance(other._body, Mapping):
50                body_match = self._to_mapping(self._body) == self._to_mapping(other._body)
51            else:
52                body_match = self._to_bytes(self._body) == self._to_bytes(other._body)
53
54            return (
55                self._parsed_url.scheme == other._parsed_url.scheme
56                and self._parsed_url.hostname == other._parsed_url.hostname
57                and self._parsed_url.path == other._parsed_url.path
58                and (
59                    ANY_QUERY_PARAMS in (self._query_params, other._query_params)
60                    or parse_qs(self._parsed_url.query) == parse_qs(other._parsed_url.query)
61                )
62                and _is_subdict(other._headers, self._headers)
63                and body_match
64            )
65        return False

If the body of any request is a Mapping, we compare as Mappings which means that the order is not important. If the body is a string, encoding ISO-8859-1 will be assumed Headers only need to be a subset of other in order to match

class HttpRequestMatcher:
 8class HttpRequestMatcher:
 9    def __init__(self, request: HttpRequest, minimum_number_of_expected_match: int):
10        self._request_to_match = request
11        self._minimum_number_of_expected_match = minimum_number_of_expected_match
12        self._actual_number_of_matches = 0
13
14    def matches(self, request: HttpRequest) -> bool:
15        hit = request.matches(self._request_to_match)
16        if hit:
17            self._actual_number_of_matches += 1
18        return hit
19
20    def has_expected_match_count(self) -> bool:
21        return self._actual_number_of_matches >= self._minimum_number_of_expected_match
22
23    @property
24    def actual_number_of_matches(self) -> int:
25        return self._actual_number_of_matches
26
27    @property
28    def request(self) -> HttpRequest:
29        return self._request_to_match
30
31    def __str__(self) -> str:
32        return (
33            f"HttpRequestMatcher("
34            f"request_to_match={self._request_to_match}, "
35            f"minimum_number_of_expected_match={self._minimum_number_of_expected_match}, "
36            f"actual_number_of_matches={self._actual_number_of_matches})"
37        )
38
39    def __eq__(self, other: Any) -> bool:
40        if isinstance(other, HttpRequestMatcher):
41            return self._request_to_match == other._request_to_match
42        return False
HttpRequestMatcher( request: HttpRequest, minimum_number_of_expected_match: int)
 9    def __init__(self, request: HttpRequest, minimum_number_of_expected_match: int):
10        self._request_to_match = request
11        self._minimum_number_of_expected_match = minimum_number_of_expected_match
12        self._actual_number_of_matches = 0
def matches(self, request: HttpRequest) -> bool:
14    def matches(self, request: HttpRequest) -> bool:
15        hit = request.matches(self._request_to_match)
16        if hit:
17            self._actual_number_of_matches += 1
18        return hit
def has_expected_match_count(self) -> bool:
20    def has_expected_match_count(self) -> bool:
21        return self._actual_number_of_matches >= self._minimum_number_of_expected_match
actual_number_of_matches: int
23    @property
24    def actual_number_of_matches(self) -> int:
25        return self._actual_number_of_matches
request: HttpRequest
27    @property
28    def request(self) -> HttpRequest:
29        return self._request_to_match
class HttpResponse:
 8class HttpResponse:
 9    def __init__(
10        self,
11        body: Union[str, bytes],
12        status_code: int = 200,
13        headers: Mapping[str, str] = MappingProxyType({}),
14    ):
15        self._body = body
16        self._status_code = status_code
17        self._headers = headers
18
19    @property
20    def body(self) -> Union[str, bytes]:
21        return self._body
22
23    @property
24    def status_code(self) -> int:
25        return self._status_code
26
27    @property
28    def headers(self) -> Mapping[str, str]:
29        return self._headers
HttpResponse( body: Union[str, bytes], status_code: int = 200, headers: Mapping[str, str] = mappingproxy({}))
 9    def __init__(
10        self,
11        body: Union[str, bytes],
12        status_code: int = 200,
13        headers: Mapping[str, str] = MappingProxyType({}),
14    ):
15        self._body = body
16        self._status_code = status_code
17        self._headers = headers
body: Union[str, bytes]
19    @property
20    def body(self) -> Union[str, bytes]:
21        return self._body
status_code: int
23    @property
24    def status_code(self) -> int:
25        return self._status_code
headers: Mapping[str, str]
27    @property
28    def headers(self) -> Mapping[str, str]:
29        return self._headers