Skip to content

Commit

Permalink
Implement a new iterator-only handler for result lists.
Browse files Browse the repository at this point in the history
Since the full list of results is unknown, and results that already
have been iterated might no longer be available, methods such as
`__len__()` and `__getitem__()` as found in `ObjectList` are
no longer available.

`__getitem__()` (e.g `result_list['tr_1234']`) replacement: use `Resource.get(resource_id)`.
`__len__()` (e.g. `len(result_list)`) replacement: exhaust the iterator and count the results.

TODO:
- More tests
- Reversed() iterator, hoe does it work?
- Add logic to return an ResultListIterator where now an ObjectList is hardcoded
- Empty list results?
  • Loading branch information
Tom Hendrikx committed Nov 25, 2022
1 parent 68aba38 commit 975c50f
Show file tree
Hide file tree
Showing 8 changed files with 269 additions and 16 deletions.
90 changes: 90 additions & 0 deletions mollie/api/objects/list.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
from typing import TYPE_CHECKING, Any, Dict, List, Tuple, Type

from .base import ObjectBase

if TYPE_CHECKING:
from ..client import Client
from ..resources.base import ResourceBase


class UnknownObject(ObjectBase):
"""Mock object for empty lists."""
Expand Down Expand Up @@ -97,3 +103,87 @@ def get_previous(self):
resource = self.object_type.get_resource_class(self.client)
resp = resource.perform_api_call(resource.REST_READ, url)
return ObjectList(resp, self.object_type, self.client)


class ResultListIterator:
"""
An iterator for result lists from the API.
You can iterate through the results. If the initial result indocates pagination,
a new result page is automatically fetched from the API when the current result page
is exhausted.
Note: This iterator should preferably replace the ObjectList as the default
return value for the Resource.list() method in the future.
"""

_last: int
_client: "Client"
next_uri: str
list_data: List[Dict[str, Any]]
result_class: Type[ObjectBase]
resource_class: Type["ResourceBase"]

def __init__(
self,
client: "Client",
data: Dict[str, Any],
resource_class: Type["ResourceBase"],
) -> None:
self._client = client
self.resource_class = resource_class

# Next line is a bit klunky
self.result_class = self.resource_class(self._client).get_resource_object({}).__class__
self.list_data, self.next_uri = self._parse_data(data)

self._last = -1

def __iter__(self):
"""Return the iterator."""
return self

def __next__(self) -> ObjectBase:
"""
Return the next result.
If the result data is exhausted, but a link to further paginated results
is available, we fetch that and return the first result of that.
"""
current = self._last + 1
try:
object_data = self.list_data[current]
self._last = current
except IndexError:
if self.next_uri:
self._reinit_from_uri(self.next_uri)
return next(self)
else:
raise StopIteration

return self.result_class(object_data, self._client)

def _parse_data(self, data: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], str]:
"""
Extract useful data from the payload.
We are interested in the following parts:
- the actual list data, unwrapped
- links to next results, when results are paginated
"""
try:
next_uri = data["_links"]["next"]["href"]
except TypeError:
next_uri = ""

resource_name = self.result_class.get_object_name()
list_data = data["_embedded"][resource_name]

return list_data, next_uri

def _reinit_from_uri(self, uri: str) -> None:
"""Fetch additional results from the API, and feed the iterator with the data."""

result = self.resource_class(self._client).perform_api_call(self.resource_class.REST_READ, uri)
self.list_data, self.next_uri = self._parse_data(result)
self._last = -1
15 changes: 11 additions & 4 deletions mollie/api/resources/base.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from typing import TYPE_CHECKING, Any, Dict, Optional
from typing import TYPE_CHECKING, Any, Dict, Optional, Union

from ..error import IdentifierError, ResponseError, ResponseHandlingError
from ..objects.list import ObjectList
from ..objects.list import ObjectList, ResultListIterator

if TYPE_CHECKING:
from ..client import Client
Expand Down Expand Up @@ -96,10 +96,17 @@ def from_url(self, url: str, params: Optional[Dict[str, Any]] = None) -> Any:


class ResourceListMixin(ResourceBase):
def list(self, **params: Optional[Dict[str, Any]]) -> ObjectList:
def list(self, **params: Optional[Dict[str, Any]]) -> Union[ObjectList, ResultListIterator]:
use_iterator = params.pop("use_iterator", False)

path = self.get_resource_path()
result = self.perform_api_call(self.REST_LIST, path, params=params)
return ObjectList(result, self.get_resource_object({}).__class__, self.client)

if use_iterator:
resource_class = self.__class__
return ResultListIterator(self.client, result, resource_class)
else:
return ObjectList(result, self.get_resource_object({}).__class__, self.client)


class ResourceUpdateMixin(ResourceBase):
Expand Down
6 changes: 3 additions & 3 deletions mollie/api/resources/chargebacks.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from typing import TYPE_CHECKING, Any, Dict, Optional
from typing import TYPE_CHECKING, Any, Dict, Optional, Union

from ..objects.chargeback import Chargeback
from ..objects.list import ObjectList
from ..objects.list import ObjectList, ResultListIterator
from .base import ResourceBase, ResourceGetMixin, ResourceListMixin

if TYPE_CHECKING:
Expand Down Expand Up @@ -74,7 +74,7 @@ def __init__(self, client: "Client", profile: "Profile") -> None:
self._profile = profile
super().__init__(client)

def list(self, **params: Optional[Dict[str, Any]]) -> ObjectList:
def list(self, **params: Optional[Dict[str, Any]]) -> Union[ObjectList, ResultListIterator]:
# Set the profileId in the query params
params.update({"profileId": self._profile.id})
return Chargebacks(self.client).list(**params)
6 changes: 3 additions & 3 deletions mollie/api/resources/methods.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union

from ..error import IdentifierError
from ..objects.issuer import Issuer
from ..objects.list import ObjectList
from ..objects.list import ObjectList, ResultListIterator
from ..objects.method import Method
from .base import ResourceBase, ResourceGetMixin, ResourceListMixin

Expand Down Expand Up @@ -87,7 +87,7 @@ def disable(self, method_id: str, **params: Optional[Dict[str, Any]]) -> Method:
result = self.perform_api_call(self.REST_DELETE, path, params=params)
return self.get_resource_object(result)

def list(self, **params: Optional[Dict[str, Any]]) -> ObjectList:
def list(self, **params: Optional[Dict[str, Any]]) -> Union[ObjectList, ResultListIterator]:
"""List the payment methods for the profile."""
params.update({"profileId": self._profile.id})
# Divert the API call to the general Methods resource
Expand Down
6 changes: 3 additions & 3 deletions mollie/api/resources/payments.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from typing import TYPE_CHECKING, Any, Dict, Optional
from typing import TYPE_CHECKING, Any, Dict, Optional, Union

from ..objects.customer import Customer
from ..objects.list import ObjectList
from ..objects.list import ObjectList, ResultListIterator
from ..objects.order import Order
from ..objects.payment import Payment
from ..objects.profile import Profile
Expand Down Expand Up @@ -147,7 +147,7 @@ def __init__(self, client: "Client", profile: Profile) -> None:
self._profile = profile
super().__init__(client)

def list(self, **params: Optional[Dict[str, Any]]) -> ObjectList:
def list(self, **params: Optional[Dict[str, Any]]) -> Union[ObjectList, ResultListIterator]:
# Set the profileId in the query params
params.update({"profileId": self._profile.id})
return Payments(self.client).list(**params)
6 changes: 3 additions & 3 deletions mollie/api/resources/refunds.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import TYPE_CHECKING, Any, Dict, Optional
from typing import TYPE_CHECKING, Any, Dict, Optional, Union

from ..objects.list import ObjectList
from ..objects.list import ObjectList, ResultListIterator
from ..objects.order import Order
from ..objects.payment import Payment
from ..objects.profile import Profile
Expand Down Expand Up @@ -99,7 +99,7 @@ def __init__(self, client: "Client", profile: Profile) -> None:
self._profile = profile
super().__init__(client)

def list(self, **params: Optional[Dict[str, Any]]) -> ObjectList:
def list(self, **params: Optional[Dict[str, Any]]) -> Union[ObjectList, ResultListIterator]:
# Set the profileId in the query params
params.update({"profileId": self._profile.id})
return Refunds(self.client).list(**params)
130 changes: 130 additions & 0 deletions tests/responses/payments_list_with_next.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
{
"_embedded": {
"payments": [
{
"resource": "payment",
"id": "tr_gHTfdq4xAB",
"mode": "test",
"createdAt": "2018-07-19T09:49:46+00:00",
"amount": {
"value": "50.00",
"currency": "EUR"
},
"description": "My first iDEAL API payment",
"method": "ideal",
"metadata": {
"order_id": 1531993786
},
"status": "open",
"isCancelable": false,
"expiresAt": "2018-07-19T10:05:16+00:00",
"profileId": "pfl_gh5wrNQ6fx",
"sequenceType": "oneoff",
"redirectUrl": "https://webshop.example.org/order/1531993786",
"webhookUrl": "https://webshop.example.org/payments/webhook/",
"settlementAmount": {
"value": "50.00",
"currency": "EUR"
},
"_links": {
"self": {
"href": "https://api.mollie.com/v2/payments/tr_gM5hTq4x4J",
"type": "application/hal+json"
},
"checkout": {
"href": "https://www.mollie.com/paymentscreen/testmode/?method=ideal&token=spyye1",
"type": "text/html"
}
}
},
{
"resource": "payment",
"id": "tr_9uhYN1zuCD",
"mode": "test",
"createdAt": "2018-07-19T09:49:35+00:00",
"amount": {
"value": "10.00",
"currency": "GBP"
},
"description": "My first iDEAL API payment",
"method": "ideal",
"metadata": {
"order_id": 1531993773
},
"status": "open",
"isCancelable": false,
"expiresAt": "2018-07-19T10:05:05+00:00",
"profileId": "pfl_gh5wrNQ6fx",
"sequenceType": "oneoff",
"redirectUrl": "https://webshop.example.org/order/1531993773",
"webhookUrl": "https://webshop.example.org/payments/webhook/",
"settlementAmount": {
"value": "50.00",
"currency": "EUR"
},
"_links": {
"self": {
"href": "https://api.mollie.com/v2/payments/tr_7UhSN1zuXS",
"type": "application/hal+json"
},
"checkout": {
"href": "https://www.mollie.com/paymentscreen/testmode/?method=ideal&token=xyrvjf",
"type": "text/html"
}
}
},
{
"resource": "payment",
"id": "tr_47HgTDE9EF",
"mode": "test",
"createdAt": "2018-07-19T09:49:37+00:00",
"amount": {
"value": "100.00",
"currency": "EUR"
},
"description": "My first iDEAL API payment",
"method": "ideal",
"metadata": {
"order_id": 1531993778
},
"status": "open",
"isCancelable": false,
"expiresAt": "2018-07-19T10:05:08+00:00",
"profileId": "pfl_gh5wrNQ6rt",
"sequenceType": "oneoff",
"redirectUrl": "https://webshop.example.org/order/1531993778",
"webhookUrl": "https://webshop.example.org/payments/webhook/",
"settlementAmount": {
"value": "50.00",
"currency": "EUR"
},
"_links": {
"self": {
"href": "https://api.mollie.com/v2/payments/tr_45xyzDE9v9",
"type": "application/hal+json"
},
"checkout": {
"href": "https://www.mollie.com/paymentscreen/testmode/?method=ideal&token=xasfjf",
"type": "text/html"
}
}
}
]
},
"count": 3,
"_links": {
"documentation": {
"href": "https://docs.mollie.com/reference/v2/payments-api/list-payments",
"type": "text/html"
},
"self": {
"href": "https://api.mollie.com/v2/payments?limit=50",
"type": "application/hal+json"
},
"previous": null,
"next": {
"href": "https://api.mollie.com/v2/payments?from=tr_gM5hTq4x4J&limit=3",
"type": "application/hal+json"
}
}
}
26 changes: 26 additions & 0 deletions tests/test_payments.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pytest
from responses import matchers

from mollie.api.error import IdentifierError
from mollie.api.objects.capture import Capture
Expand Down Expand Up @@ -32,6 +33,31 @@ def test_list_payments(client, response):
assert_list_object(payments, Payment)


def test_list_payments_use_iterator(client, response):
"""Retrieve a list of payments using the new object list."""
response.get(
"https://api.mollie.com/v2/payments",
"payments_list_with_next",
match=[matchers.query_string_matcher("limit=3")],
)
response.get(
"https://api.mollie.com/v2/payments",
"payments_list",
match=[matchers.query_string_matcher("from=tr_gM5hTq4x4J&limit=3")],
)

payments = client.payments.list(use_iterator=True, limit=3)
payment_ids = [p.id for p in payments]
assert payment_ids == [
"tr_gHTfdq4xAB",
"tr_9uhYN1zuCD",
"tr_47HgTDE9EF",
"tr_gM5hTq4x4J",
"tr_7UhSN1zuXS",
"tr_45xyzDE9v9",
]


def test_create_payment(client, response):
"""Create a new payment."""
response.post("https://api.mollie.com/v2/payments", "payment_single")
Expand Down

0 comments on commit 975c50f

Please sign in to comment.