From 192ebd5ae47302d13f0426ec8a7bda02c0d3f521 Mon Sep 17 00:00:00 2001 From: Dominic Charley-Roy Date: Wed, 28 Jul 2021 11:12:31 -0400 Subject: [PATCH 1/5] Add support for SearchResult. --- stripe/api_resources/__init__.py | 1 + stripe/api_resources/abstract/__init__.py | 3 + .../abstract/searchable_api_resource.py | 28 +++ stripe/api_resources/search_result_object.py | 111 +++++++++ stripe/object_classes.py | 1 + .../test_search_result_object.py | 215 ++++++++++++++++++ 6 files changed, 359 insertions(+) create mode 100644 stripe/api_resources/abstract/searchable_api_resource.py create mode 100644 stripe/api_resources/search_result_object.py create mode 100644 tests/api_resources/test_search_result_object.py diff --git a/stripe/api_resources/__init__.py b/stripe/api_resources/__init__.py index 0e78220d4..02be5c201 100644 --- a/stripe/api_resources/__init__.py +++ b/stripe/api_resources/__init__.py @@ -5,6 +5,7 @@ from stripe.api_resources.error_object import ErrorObject, OAuthErrorObject from stripe.api_resources.list_object import ListObject +from stripe.api_resources.search_result_object import SearchResultObject from stripe.api_resources import billing_portal from stripe.api_resources import checkout diff --git a/stripe/api_resources/abstract/__init__.py b/stripe/api_resources/abstract/__init__.py index 74d7b5f26..026213449 100644 --- a/stripe/api_resources/abstract/__init__.py +++ b/stripe/api_resources/abstract/__init__.py @@ -19,6 +19,9 @@ from stripe.api_resources.abstract.listable_api_resource import ( ListableAPIResource, ) +from stripe.api_resources.abstract.searchable_api_resource import ( + SearchableAPIResource, +) from stripe.api_resources.abstract.verify_mixin import VerifyMixin from stripe.api_resources.abstract.custom_method import custom_method diff --git a/stripe/api_resources/abstract/searchable_api_resource.py b/stripe/api_resources/abstract/searchable_api_resource.py new file mode 100644 index 000000000..10f17d792 --- /dev/null +++ b/stripe/api_resources/abstract/searchable_api_resource.py @@ -0,0 +1,28 @@ +from __future__ import absolute_import, division, print_function + +from stripe import api_requestor, util +from stripe.api_resources.abstract.api_resource import APIResource + + +class SearchableAPIResource(APIResource): + @classmethod + def _search( + cls, + search_url, + api_key=None, + stripe_version=None, + stripe_account=None, + **params + ): + requestor = api_requestor.APIRequestor( + api_key, + api_base=cls.api_base(), + api_version=stripe_version, + account=stripe_account, + ) + response, api_key = requestor.request("get", search_url, params) + stripe_object = util.convert_to_stripe_object( + response, api_key, stripe_version, stripe_account + ) + stripe_object._retrieve_params = params + return stripe_object diff --git a/stripe/api_resources/search_result_object.py b/stripe/api_resources/search_result_object.py new file mode 100644 index 000000000..1ce041e97 --- /dev/null +++ b/stripe/api_resources/search_result_object.py @@ -0,0 +1,111 @@ +from __future__ import absolute_import, division, print_function + +from stripe import api_requestor, six, util +from stripe.stripe_object import StripeObject + + +class SearchResultObject(StripeObject): + OBJECT_NAME = "search_result" + + def search( + self, api_key=None, stripe_version=None, stripe_account=None, **params + ): + stripe_object = self._request( + "get", + self.get("url"), + api_key=api_key, + stripe_version=stripe_version, + stripe_account=stripe_account, + **params + ) + stripe_object._retrieve_params = params + return stripe_object + + def _request( + self, + method_, + url_, + api_key=None, + idempotency_key=None, + stripe_version=None, + stripe_account=None, + **params + ): + api_key = api_key or self.api_key + stripe_version = stripe_version or self.stripe_version + stripe_account = stripe_account or self.stripe_account + + requestor = api_requestor.APIRequestor( + api_key, api_version=stripe_version, account=stripe_account + ) + headers = util.populate_headers(idempotency_key) + response, api_key = requestor.request(method_, url_, params, headers) + stripe_object = util.convert_to_stripe_object( + response, api_key, stripe_version, stripe_account + ) + return stripe_object + + def __getitem__(self, k): + if isinstance(k, six.string_types): + return super(SearchResultObject, self).__getitem__(k) + else: + raise KeyError( + "You tried to access the %s index, but SearchResultObject types " + "only support string keys. (HINT: Search calls return an object " + "with a 'data' (which is the data array). You likely want to " + "call .data[%s])" % (repr(k), repr(k)) + ) + + def __iter__(self): + return getattr(self, "data", []).__iter__() + + def __len__(self): + return getattr(self, "data", []).__len__() + + def auto_paging_iter(self): + page = self + + while True: + for item in page: + yield item + page = page.next_search_result_page() + + if page.is_empty: + break + + @classmethod + def empty_search_result( + cls, api_key=None, stripe_version=None, stripe_account=None + ): + return cls.construct_from( + {"data": [], "has_more": False}, + key=api_key, + stripe_version=stripe_version, + stripe_account=stripe_account, + last_response=None, + ) + + @property + def is_empty(self): + return not self.data + + def next_search_result_page( + self, api_key=None, stripe_version=None, stripe_account=None, **params + ): + if not self.has_more: + return self.empty_search_result( + api_key=api_key, + stripe_version=stripe_version, + stripe_account=stripe_account, + ) + + params_with_filters = self._retrieve_params.copy() + params_with_filters.update({"next_page": self.next_page}) + params_with_filters.update(params) + + return self.search( + api_key=api_key, + stripe_version=stripe_version, + stripe_account=stripe_account, + **params_with_filters + ) diff --git a/stripe/object_classes.py b/stripe/object_classes.py index 479dd9dda..49de8c81c 100644 --- a/stripe/object_classes.py +++ b/stripe/object_classes.py @@ -7,6 +7,7 @@ OBJECT_CLASSES = { # data structures api_resources.ListObject.OBJECT_NAME: api_resources.ListObject, + api_resources.SearchResultObject.OBJECT_NAME: api_resources.SearchResultObject, # business objects api_resources.Account.OBJECT_NAME: api_resources.Account, api_resources.AccountLink.OBJECT_NAME: api_resources.AccountLink, diff --git a/tests/api_resources/test_search_result_object.py b/tests/api_resources/test_search_result_object.py new file mode 100644 index 000000000..dfe8c4450 --- /dev/null +++ b/tests/api_resources/test_search_result_object.py @@ -0,0 +1,215 @@ +from __future__ import absolute_import, division, print_function + +import json + +import pytest + +import stripe + + +class TestSearchResultObject(object): + @pytest.fixture + def search_result_object(self): + return stripe.SearchResultObject.construct_from( + {"object": "search_result", "url": "/my/path", "data": ["foo"]}, + "mykey", + ) + + def test_search(self, request_mock, search_result_object): + request_mock.stub_request( + "get", + "/my/path", + { + "object": "search_result", + "data": [{"object": "charge", "foo": "bar"}], + }, + ) + + res = search_result_object.search( + myparam="you", stripe_account="acct_123" + ) + + request_mock.assert_requested( + "get", "/my/path", {"myparam": "you"}, None + ) + assert isinstance(res, stripe.SearchResultObject) + assert res.stripe_account == "acct_123" + assert isinstance(res.data, list) + assert isinstance(res.data[0], stripe.Charge) + assert res.data[0].foo == "bar" + + def test_is_empty(self): + sro = stripe.SearchResultObject.construct_from({"data": []}, None) + assert sro.is_empty is True + + def test_empty_search_result(self): + sro = stripe.SearchResultObject.empty_search_result() + assert sro.is_empty + + def test_iter(self): + arr = [{"id": 1}, {"id": 2}, {"id": 3}] + expected = stripe.util.convert_to_stripe_object(arr) + sro = stripe.SearchResultObject.construct_from({"data": arr}, None) + assert list(sro) == expected + + def test_len(self, search_result_object): + assert len(search_result_object) == 1 + + def test_bool(self, search_result_object): + assert search_result_object + + empty = stripe.SearchResultObject.construct_from( + {"object": "list", "url": "/my/path", "data": []}, "mykey" + ) + assert bool(empty) is False + + def test_next_search_result_page(self, request_mock): + sro = stripe.SearchResultObject.construct_from( + { + "object": "list", + "data": [{"id": 1}], + "has_more": True, + "next_page": "next_page_token", + "url": "/things", + }, + None, + ) + + request_mock.stub_request( + "get", + "/things", + { + "object": "list", + "data": [{"id": 2}], + "has_more": False, + "url": "/things", + }, + ) + + next_sro = sro.next_search_result_page() + + request_mock.assert_requested( + "get", "/things", {"next_page": "next_page_token"}, None + ) + assert not next_sro.is_empty + assert next_sro.data[0].id == 2 + + def test_next_search_result_page_with_filters(self, request_mock): + sro = stripe.SearchResultObject.construct_from( + { + "object": "list", + "data": [{"id": 1}], + "has_more": True, + "next_page": "next_page_token", + "url": "/things", + }, + None, + ) + sro._retrieve_params = {"expand": ["data.source"], "limit": 3} + + request_mock.stub_request( + "get", + "/things", + { + "object": "list", + "data": [{"id": 2}], + "has_more": False, + "url": "/things", + }, + ) + + next_sro = sro.next_search_result_page() + assert next_sro._retrieve_params == { + "expand": ["data.source"], + "limit": 3, + "next_page": "next_page_token", + } + + def test_next_search_result_page_empty_search_result(self): + sro = stripe.SearchResultObject.construct_from( + { + "object": "list", + "data": [{"id": 1}], + "has_more": False, + "url": "/things", + }, + None, + ) + + next_sro = sro.next_search_result_page() + assert next_sro == stripe.SearchResultObject.empty_search_result() + + def test_serialize_empty_search_result(self): + empty = stripe.SearchResultObject.construct_from( + {"object": "list", "data": []}, "mykey" + ) + serialized = str(empty) + deserialized = stripe.SearchResultObject.construct_from( + json.loads(serialized), "mykey" + ) + assert deserialized == empty + + def test_serialize_nested_empty_search_result(self): + empty = stripe.SearchResultObject.construct_from( + {"object": "list", "data": []}, "mykey" + ) + obj = stripe.stripe_object.StripeObject.construct_from( + {"nested": empty}, "mykey" + ) + serialized = str(obj) + deserialized = stripe.stripe_object.StripeObject.construct_from( + json.loads(serialized), "mykey" + ) + assert deserialized.nested == empty + + +class TestAutoPaging: + @staticmethod + def pageable_model_response(ids, has_more, next_page_token): + model = { + "object": "search_result", + "url": "/v1/pageablemodels", + "data": [{"id": id, "object": "pageablemodel"} for id in ids], + "has_more": has_more, + } + + if next_page_token is not None: + model["next_page"] = next_page_token + + return model + + def test_iter_one_page(self, request_mock): + sro = stripe.SearchResultObject.construct_from( + self.pageable_model_response(["pm_123", "pm_124"], False, None), + "mykey", + ) + + request_mock.assert_no_request() + + seen = [item["id"] for item in sro.auto_paging_iter()] + + assert seen == ["pm_123", "pm_124"] + + def test_iter_two_pages(self, request_mock): + sro = stripe.SearchResultObject.construct_from( + self.pageable_model_response(["pm_123", "pm_124"], True, "token"), + "mykey", + ) + sro._retrieve_params = {"foo": "bar"} + + request_mock.stub_request( + "get", + "/v1/pageablemodels", + self.pageable_model_response(["pm_125", "pm_126"], False, None), + ) + + seen = [item["id"] for item in sro.auto_paging_iter()] + + request_mock.assert_requested( + "get", + "/v1/pageablemodels", + {"next_page": "token", "foo": "bar"}, + None, + ) + + assert seen == ["pm_123", "pm_124", "pm_125", "pm_126"] From d7027fe8e24b279db49fba664b3e13be0a1c00f9 Mon Sep 17 00:00:00 2001 From: Dominic Charley-Roy Date: Wed, 16 Mar 2022 10:14:08 -0400 Subject: [PATCH 2/5] Update for new search API shape. --- stripe/api_resources/search_result_object.py | 4 ++-- tests/api_resources/test_search_result_object.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/stripe/api_resources/search_result_object.py b/stripe/api_resources/search_result_object.py index 1ce041e97..c2f781942 100644 --- a/stripe/api_resources/search_result_object.py +++ b/stripe/api_resources/search_result_object.py @@ -78,7 +78,7 @@ def empty_search_result( cls, api_key=None, stripe_version=None, stripe_account=None ): return cls.construct_from( - {"data": [], "has_more": False}, + {"data": [], "has_more": False, "next_page": None}, key=api_key, stripe_version=stripe_version, stripe_account=stripe_account, @@ -100,7 +100,7 @@ def next_search_result_page( ) params_with_filters = self._retrieve_params.copy() - params_with_filters.update({"next_page": self.next_page}) + params_with_filters.update({"page": self.next_page}) params_with_filters.update(params) return self.search( diff --git a/tests/api_resources/test_search_result_object.py b/tests/api_resources/test_search_result_object.py index dfe8c4450..da8656d69 100644 --- a/tests/api_resources/test_search_result_object.py +++ b/tests/api_resources/test_search_result_object.py @@ -89,7 +89,7 @@ def test_next_search_result_page(self, request_mock): next_sro = sro.next_search_result_page() request_mock.assert_requested( - "get", "/things", {"next_page": "next_page_token"}, None + "get", "/things", {"page": "next_page_token"}, None ) assert not next_sro.is_empty assert next_sro.data[0].id == 2 @@ -114,6 +114,7 @@ def test_next_search_result_page_with_filters(self, request_mock): "object": "list", "data": [{"id": 2}], "has_more": False, + "next_page": None, "url": "/things", }, ) @@ -122,7 +123,7 @@ def test_next_search_result_page_with_filters(self, request_mock): assert next_sro._retrieve_params == { "expand": ["data.source"], "limit": 3, - "next_page": "next_page_token", + "page": "next_page_token", } def test_next_search_result_page_empty_search_result(self): @@ -131,6 +132,7 @@ def test_next_search_result_page_empty_search_result(self): "object": "list", "data": [{"id": 1}], "has_more": False, + "next_page": None, "url": "/things", }, None, @@ -171,11 +173,9 @@ def pageable_model_response(ids, has_more, next_page_token): "url": "/v1/pageablemodels", "data": [{"id": id, "object": "pageablemodel"} for id in ids], "has_more": has_more, + "next_page": next_page_token } - if next_page_token is not None: - model["next_page"] = next_page_token - return model def test_iter_one_page(self, request_mock): @@ -208,7 +208,7 @@ def test_iter_two_pages(self, request_mock): request_mock.assert_requested( "get", "/v1/pageablemodels", - {"next_page": "token", "foo": "bar"}, + {"page": "token", "foo": "bar"}, None, ) From 4908063f7b85ca551f835d35ee2974c5eb9de383 Mon Sep 17 00:00:00 2001 From: Dominic Charley-Roy Date: Wed, 16 Mar 2022 10:59:01 -0400 Subject: [PATCH 3/5] Formatting. --- tests/api_resources/test_search_result_object.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/api_resources/test_search_result_object.py b/tests/api_resources/test_search_result_object.py index da8656d69..379131eb0 100644 --- a/tests/api_resources/test_search_result_object.py +++ b/tests/api_resources/test_search_result_object.py @@ -173,7 +173,7 @@ def pageable_model_response(ids, has_more, next_page_token): "url": "/v1/pageablemodels", "data": [{"id": id, "object": "pageablemodel"} for id in ids], "has_more": has_more, - "next_page": next_page_token + "next_page": next_page_token, } return model From 1c3017a89835982f807cf88cd7e93e41bd14f59d Mon Sep 17 00:00:00 2001 From: Dominic Charley-Roy Date: Wed, 16 Mar 2022 11:44:01 -0400 Subject: [PATCH 4/5] Add Searchable test. --- .../abstract/test_searchable_api_resource.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tests/api_resources/abstract/test_searchable_api_resource.py diff --git a/tests/api_resources/abstract/test_searchable_api_resource.py b/tests/api_resources/abstract/test_searchable_api_resource.py new file mode 100644 index 000000000..9d8be817a --- /dev/null +++ b/tests/api_resources/abstract/test_searchable_api_resource.py @@ -0,0 +1,40 @@ +from __future__ import absolute_import, division, print_function + +import stripe + + +class TestSearchableAPIResource(object): + class MySearchable(stripe.api_resources.abstract.SearchableAPIResource): + OBJECT_NAME = "mysearchable" + + @classmethod + def search(cls, *args, **kwargs): + return cls._search( + search_url="/v1/mysearchables/search", *args, **kwargs + ) + + def test_search(self, request_mock): + request_mock.stub_request( + "get", + "/v1/mysearchables/search", + { + "object": "list", + "data": [ + {"object": "charge", "name": "jose"}, + {"object": "charge", "name": "curly"}, + ], + "url": "/v1/charges", + "has_more": False, + }, + rheaders={"request-id": "req_id"}, + ) + + res = self.MySearchable.search(query='currency:"CAD"') + request_mock.assert_requested("get", "/v1/mysearchables/search", {}) + assert len(res.data) == 2 + assert all(isinstance(obj, stripe.Charge) for obj in res.data) + assert res.data[0].name == "jose" + assert res.data[1].name == "curly" + + assert res.last_response is not None + assert res.last_response.request_id == "req_id" From c8ab4f871efbb00707c4419f22a881003bb879bc Mon Sep 17 00:00:00 2001 From: Dominic Charley-Roy Date: Wed, 16 Mar 2022 13:11:00 -0400 Subject: [PATCH 5/5] Add multi-page test. --- .../abstract/test_searchable_api_resource.py | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/tests/api_resources/abstract/test_searchable_api_resource.py b/tests/api_resources/abstract/test_searchable_api_resource.py index 9d8be817a..b0afd1283 100644 --- a/tests/api_resources/abstract/test_searchable_api_resource.py +++ b/tests/api_resources/abstract/test_searchable_api_resource.py @@ -18,13 +18,14 @@ def test_search(self, request_mock): "get", "/v1/mysearchables/search", { - "object": "list", + "object": "search_result", "data": [ {"object": "charge", "name": "jose"}, {"object": "charge", "name": "curly"}, ], "url": "/v1/charges", "has_more": False, + "next_page": None, }, rheaders={"request-id": "req_id"}, ) @@ -38,3 +39,54 @@ def test_search(self, request_mock): assert res.last_response is not None assert res.last_response.request_id == "req_id" + + def test_search_multiple_pages(self, request_mock): + request_mock.stub_request( + "get", + "/v1/mysearchables/search", + { + "object": "search_result", + "data": [ + {"object": "charge", "name": "jose"}, + {"object": "charge", "name": "curly"}, + ], + "url": "/v1/charges", + "has_more": True, + "next_page": "next-page-token", + }, + rheaders={"request-id": "req_id"}, + ) + + res = self.MySearchable.search(query='currency:"CAD"') + request_mock.assert_requested( + "get", "/v1/mysearchables/search", {"query": 'currency:"CAD"'} + ) + + assert res.next_page == "next-page-token" + + request_mock.stub_request( + "get", + "/v1/mysearchables/search", + { + "object": "list", + "data": [ + {"object": "charge", "name": "test"}, + ], + "url": "/v1/charges", + "has_more": False, + "next_page": None, + }, + rheaders={"request-id": "req_id"}, + ) + res2 = self.MySearchable.search( + query='currency:"CAD"', page=res.next_page + ) + request_mock.assert_requested( + "get", + "/v1/mysearchables/search", + {"page": "next-page-token", "query": 'currency:"CAD"'}, + ) + + assert len(res2.data) == 1 + assert all(isinstance(obj, stripe.Charge) for obj in res2.data) + assert res2.data[0].name == "test"