From 9c7e27ab7e30a50853aa23139d581ceab3b5e619 Mon Sep 17 00:00:00 2001
From: Dominic Charley-Roy <78050200+dcr-stripe@users.noreply.github.com>
Date: Fri, 11 Mar 2022 15:55:42 -0500
Subject: [PATCH] Add support for SearchResult objects. (#1251)
---
init.php | 2 +
lib/ApiOperations/Search.php | 37 ++++
lib/BaseStripeClient.php | 24 +++
lib/SearchResult.php | 230 +++++++++++++++++++++++++
lib/Service/AbstractService.php | 5 +
lib/Util/ObjectTypes.php | 1 +
tests/Stripe/SearchResultTest.php | 274 ++++++++++++++++++++++++++++++
7 files changed, 573 insertions(+)
create mode 100644 lib/ApiOperations/Search.php
create mode 100644 lib/SearchResult.php
create mode 100644 tests/Stripe/SearchResultTest.php
diff --git a/init.php b/init.php
index 4c94ede34..b4a41dbe6 100644
--- a/init.php
+++ b/init.php
@@ -54,6 +54,7 @@
require __DIR__ . '/lib/ApiOperations/NestedResource.php';
require __DIR__ . '/lib/ApiOperations/Request.php';
require __DIR__ . '/lib/ApiOperations/Retrieve.php';
+require __DIR__ . '/lib/ApiOperations/Search.php';
require __DIR__ . '/lib/ApiOperations/Update.php';
// Plumbing
@@ -142,6 +143,7 @@
require __DIR__ . '/lib/Reporting/ReportRun.php';
require __DIR__ . '/lib/Reporting/ReportType.php';
require __DIR__ . '/lib/Review.php';
+require __DIR__ . '/lib/SearchResult.php';
require __DIR__ . '/lib/SetupAttempt.php';
require __DIR__ . '/lib/SetupIntent.php';
require __DIR__ . '/lib/ShippingRate.php';
diff --git a/lib/ApiOperations/Search.php b/lib/ApiOperations/Search.php
new file mode 100644
index 000000000..09472ebaa
--- /dev/null
+++ b/lib/ApiOperations/Search.php
@@ -0,0 +1,37 @@
+json, $opts);
+ if (!($obj instanceof \Stripe\SearchResult)) {
+ throw new \Stripe\Exception\UnexpectedValueException(
+ 'Expected type ' . \Stripe\SearchResult::class . ', got "' . \get_class($obj) . '" instead.'
+ );
+ }
+ $obj->setLastResponse($response);
+ $obj->setFilters($params);
+
+ return $obj;
+ }
+}
diff --git a/lib/BaseStripeClient.php b/lib/BaseStripeClient.php
index bfdfe8677..b0b2e7e57 100644
--- a/lib/BaseStripeClient.php
+++ b/lib/BaseStripeClient.php
@@ -182,6 +182,30 @@ public function requestCollection($method, $path, $params, $opts)
return $obj;
}
+ /**
+ * Sends a request to Stripe's API.
+ *
+ * @param string $method the HTTP method
+ * @param string $path the path of the request
+ * @param array $params the parameters of the request
+ * @param array|\Stripe\Util\RequestOptions $opts the special modifiers of the request
+ *
+ * @return \Stripe\SearchResult of ApiResources
+ */
+ public function requestSearchResult($method, $path, $params, $opts)
+ {
+ $obj = $this->request($method, $path, $params, $opts);
+ if (!($obj instanceof \Stripe\SearchResult)) {
+ $received_class = \get_class($obj);
+ $msg = "Expected to receive `Stripe\\SearchResult` object from Stripe API. Instead received `{$received_class}`.";
+
+ throw new \Stripe\Exception\UnexpectedValueException($msg);
+ }
+ $obj->setFilters($params);
+
+ return $obj;
+ }
+
/**
* @param \Stripe\Util\RequestOptions $opts
*
diff --git a/lib/SearchResult.php b/lib/SearchResult.php
new file mode 100644
index 000000000..c854dfae6
--- /dev/null
+++ b/lib/SearchResult.php
@@ -0,0 +1,230 @@
+Collection in that they both wrap
+ * around a list of objects and provide pagination. However the
+ * SearchResult
object paginates by relying on a
+ * next_page
token included in the response rather than using
+ * object IDs and a starting_before
/ending_after
+ * parameter. Thus, SearchResult
only supports forwards pagination.
+ *
+ * @template TStripeObject of StripeObject
+ * @template-implements \IteratorAggregate
+ *
+ * @property string $object
+ * @property string $url
+ * @property string $next_page
+ * @property bool $has_more
+ * @property TStripeObject[] $data
+ */
+class SearchResult extends StripeObject implements \Countable, \IteratorAggregate
+{
+ const OBJECT_NAME = 'search_result';
+
+ use ApiOperations\Request;
+
+ /** @var array */
+ protected $filters = [];
+
+ /**
+ * @return string the base URL for the given class
+ */
+ public static function baseUrl()
+ {
+ return Stripe::$apiBase;
+ }
+
+ /**
+ * Returns the filters.
+ *
+ * @return array the filters
+ */
+ public function getFilters()
+ {
+ return $this->filters;
+ }
+
+ /**
+ * Sets the filters, removing paging options.
+ *
+ * @param array $filters the filters
+ */
+ public function setFilters($filters)
+ {
+ $this->filters = $filters;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function offsetGet($k)
+ {
+ if (\is_string($k)) {
+ return parent::offsetGet($k);
+ }
+ $msg = "You tried to access the {$k} index, but SearchResult " .
+ '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[{$k}])";
+
+ throw new Exception\InvalidArgumentException($msg);
+ }
+
+ /**
+ * @param null|array $params
+ * @param null|array|string $opts
+ *
+ * @throws Exception\ApiErrorException
+ *
+ * @return SearchResult
+ */
+ public function all($params = null, $opts = null)
+ {
+ self::_validateParams($params);
+ list($url, $params) = $this->extractPathAndUpdateParams($params);
+
+ list($response, $opts) = $this->_request('get', $url, $params, $opts);
+ $obj = Util\Util::convertToStripeObject($response, $opts);
+ if (!($obj instanceof \Stripe\SearchResult)) {
+ throw new \Stripe\Exception\UnexpectedValueException(
+ 'Expected type ' . \Stripe\SearchResult::class . ', got "' . \get_class($obj) . '" instead.'
+ );
+ }
+ $obj->setFilters($params);
+
+ return $obj;
+ }
+
+ /**
+ * @return int the number of objects in the current page
+ */
+ #[\ReturnTypeWillChange]
+ public function count()
+ {
+ return \count($this->data);
+ }
+
+ /**
+ * @return \ArrayIterator an iterator that can be used to iterate
+ * across objects in the current page
+ */
+ #[\ReturnTypeWillChange]
+ public function getIterator()
+ {
+ return new \ArrayIterator($this->data);
+ }
+
+ /**
+ * @return \Generator|TStripeObject[] A generator that can be used to
+ * iterate across all objects across all pages. As page boundaries are
+ * encountered, the next page will be fetched automatically for
+ * continued iteration.
+ */
+ public function autoPagingIterator()
+ {
+ $page = $this;
+
+ while (true) {
+ foreach ($page as $item) {
+ yield $item;
+ }
+ $page = $page->nextPage();
+
+ if ($page->isEmpty()) {
+ break;
+ }
+ }
+ }
+
+ /**
+ * Returns an empty set of search results. This is returned from
+ * {@see nextPage()} when we know that there isn't a next page in order to
+ * replicate the behavior of the API when it attempts to return a page
+ * beyond the last.
+ *
+ * @param null|array|string $opts
+ *
+ * @return SearchResult
+ */
+ public static function emptySearchResult($opts = null)
+ {
+ return SearchResult::constructFrom(['data' => []], $opts);
+ }
+
+ /**
+ * Returns true if the page object contains no element.
+ *
+ * @return bool
+ */
+ public function isEmpty()
+ {
+ return empty($this->data);
+ }
+
+ /**
+ * Fetches the next page in the resource list (if there is one).
+ *
+ * This method will try to respect the limit of the current page. If none
+ * was given, the default limit will be fetched again.
+ *
+ * @param null|array $params
+ * @param null|array|string $opts
+ *
+ * @return SearchResult
+ */
+ public function nextPage($params = null, $opts = null)
+ {
+ if (!$this->has_more) {
+ return static::emptySearchResult($opts);
+ }
+
+ $params = \array_merge(
+ $this->filters ?: [],
+ ['page' => $this->next_page],
+ $params ?: []
+ );
+
+ return $this->all($params, $opts);
+ }
+
+ /**
+ * Gets the first item from the current page. Returns `null` if the current page is empty.
+ *
+ * @return null|TStripeObject
+ */
+ public function first()
+ {
+ return \count($this->data) > 0 ? $this->data[0] : null;
+ }
+
+ /**
+ * Gets the last item from the current page. Returns `null` if the current page is empty.
+ *
+ * @return null|TStripeObject
+ */
+ public function last()
+ {
+ return \count($this->data) > 0 ? $this->data[\count($this->data) - 1] : null;
+ }
+
+ private function extractPathAndUpdateParams($params)
+ {
+ $url = \parse_url($this->url);
+
+ if (!isset($url['path'])) {
+ throw new Exception\UnexpectedValueException("Could not parse list url into parts: {$url}");
+ }
+
+ if (isset($url['query'])) {
+ // If the URL contains a query param, parse it out into $params so they
+ // don't interact weirdly with each other.
+ $query = [];
+ \parse_str($url['query'], $query);
+ $params = \array_merge($params ?: [], $query);
+ }
+
+ return [$url['path'], $params];
+ }
+}
diff --git a/lib/Service/AbstractService.php b/lib/Service/AbstractService.php
index 12071c827..145af6759 100644
--- a/lib/Service/AbstractService.php
+++ b/lib/Service/AbstractService.php
@@ -85,6 +85,11 @@ protected function requestCollection($method, $path, $params, $opts)
return $this->getClient()->requestCollection($method, $path, static::formatParams($params), $opts);
}
+ protected function requestSearchResult($method, $path, $params, $opts)
+ {
+ return $this->getClient()->requestSearchResult($method, $path, static::formatParams($params), $opts);
+ }
+
protected function buildPath($basePath, ...$ids)
{
foreach ($ids as $id) {
diff --git a/lib/Util/ObjectTypes.php b/lib/Util/ObjectTypes.php
index 057364089..53e38a944 100644
--- a/lib/Util/ObjectTypes.php
+++ b/lib/Util/ObjectTypes.php
@@ -78,6 +78,7 @@ class ObjectTypes
\Stripe\Reporting\ReportRun::OBJECT_NAME => \Stripe\Reporting\ReportRun::class,
\Stripe\Reporting\ReportType::OBJECT_NAME => \Stripe\Reporting\ReportType::class,
\Stripe\Review::OBJECT_NAME => \Stripe\Review::class,
+ \Stripe\SearchResult::OBJECT_NAME => \Stripe\SearchResult::class,
\Stripe\SetupAttempt::OBJECT_NAME => \Stripe\SetupAttempt::class,
\Stripe\SetupIntent::OBJECT_NAME => \Stripe\SetupIntent::class,
\Stripe\ShippingRate::OBJECT_NAME => \Stripe\ShippingRate::class,
diff --git a/tests/Stripe/SearchResultTest.php b/tests/Stripe/SearchResultTest.php
new file mode 100644
index 000000000..329d3e7db
--- /dev/null
+++ b/tests/Stripe/SearchResultTest.php
@@ -0,0 +1,274 @@
+fixture = SearchResult::constructFrom([
+ 'data' => [['id' => '1']],
+ 'has_more' => true,
+ 'url' => '/things',
+ 'next_page' => 'WzEuMl0=',
+ ]);
+ }
+
+ public function testOffsetGetNumericIndex()
+ {
+ $this->expectException(\Stripe\Exception\InvalidArgumentException::class);
+ $this->compatExpectExceptionMessageMatches('/You tried to access the \\d index/');
+
+ $this->fixture[0];
+ }
+
+ public function testCanList()
+ {
+ $this->stubRequest(
+ 'GET',
+ '/things',
+ [],
+ null,
+ false,
+ [
+ 'object' => 'search_result',
+ 'data' => [['id' => '1']],
+ 'has_more' => true,
+ 'url' => '/things',
+ ]
+ );
+
+ $resources = $this->fixture->all();
+ $this->compatAssertIsArray($resources->data);
+ }
+
+ public function testCanCount()
+ {
+ $SearchResult = SearchResult::constructFrom([
+ 'data' => [['id' => '1']],
+ ]);
+ static::assertCount(1, $SearchResult);
+
+ $SearchResult = SearchResult::constructFrom([
+ 'data' => [['id' => '1'], ['id' => '2'], ['id' => '3']],
+ ]);
+ static::assertCount(3, $SearchResult);
+ }
+
+ public function testCanIterate()
+ {
+ $SearchResult = SearchResult::constructFrom([
+ 'data' => [['id' => '1'], ['id' => '2'], ['id' => '3']],
+ 'has_more' => true,
+ 'url' => '/things',
+ 'next_page' => 'WzEuMl0=',
+ ]);
+
+ $seen = [];
+ foreach ($SearchResult as $item) {
+ $seen[] = $item['id'];
+ }
+
+ static::assertSame(['1', '2', '3'], $seen);
+ }
+
+ public function testSupportsIteratorToArray()
+ {
+ $seen = [];
+ foreach (\iterator_to_array($this->fixture) as $item) {
+ $seen[] = $item['id'];
+ }
+
+ static::assertSame(['1'], $seen);
+ }
+
+ public function testProvidesAutoPagingIterator()
+ {
+ $this->stubRequest(
+ 'GET',
+ '/things',
+ [
+ 'page' => 'WzEuMl0=',
+ ],
+ null,
+ false,
+ [
+ 'object' => 'search_result',
+ 'data' => [['id' => '2'], ['id' => '3']],
+ 'has_more' => false,
+ ]
+ );
+
+ $seen = [];
+ foreach ($this->fixture->autoPagingIterator() as $item) {
+ $seen[] = $item['id'];
+ }
+
+ static::assertSame(['1', '2', '3'], $seen);
+ }
+
+ public function testAutoPagingIteratorReusesFilters()
+ {
+ $this->stubRequest(
+ 'GET',
+ '/things',
+ [
+ 'query' => 'metadata["foo"]:"bar"',
+ 'limit' => 3,
+ 'page' => 'WzEuMl0=',
+ ],
+ null,
+ false,
+ [
+ 'object' => 'search_result',
+ 'data' => [['id' => '2'], ['id' => '3']],
+ 'has_more' => false,
+ ]
+ );
+
+ $this->fixture->setFilters([
+ 'query' => 'metadata["foo"]:"bar"',
+ 'limit' => 3,
+ ]);
+
+ $seen = [];
+ foreach ($this->fixture->autoPagingIterator() as $item) {
+ $seen[] = $item['id'];
+ }
+
+ static::assertSame(['1', '2', '3'], $seen);
+ }
+
+ public function testAutoPagingIteratorSupportsIteratorToArray()
+ {
+ $this->stubRequest(
+ 'GET',
+ '/things',
+ [
+ 'page' => 'WzEuMl0=',
+ ],
+ null,
+ false,
+ [
+ 'object' => 'search_result',
+ 'data' => [['id' => '2'], ['id' => '3']],
+ 'has_more' => false,
+ ]
+ );
+
+ $seen = [];
+ foreach (\iterator_to_array($this->fixture->autoPagingIterator()) as $item) {
+ $seen[] = $item['id'];
+ }
+
+ static::assertSame(['1', '2', '3'], $seen);
+ }
+
+ public function testEmptySearchResult()
+ {
+ $emptySearchResult = SearchResult::emptySearchResult();
+ static::assertSame([], $emptySearchResult->data);
+ }
+
+ public function testIsEmpty()
+ {
+ $empty = SearchResult::constructFrom(['data' => []]);
+ static::assertTrue($empty->isEmpty());
+
+ $notEmpty = SearchResult::constructFrom(['data' => [['id' => '1']]]);
+ static::assertFalse($notEmpty->isEmpty());
+ }
+
+ public function testNextPage()
+ {
+ $this->stubRequest(
+ 'GET',
+ '/things',
+ [
+ 'page' => 'WzEuMl0=',
+ ],
+ null,
+ false,
+ [
+ 'object' => 'search_result',
+ 'data' => [['id' => '2'], ['id' => '3']],
+ 'has_more' => false,
+ ]
+ );
+
+ $nextPage = $this->fixture->nextPage();
+ $ids = [];
+ foreach ($nextPage->data as $element) {
+ $ids[] = $element['id'];
+ }
+ static::assertSame(['2', '3'], $ids);
+ }
+
+ public function testNextPageReusesFilters()
+ {
+ $this->stubRequest(
+ 'GET',
+ '/things',
+ [
+ 'query' => 'metadata["foo"]:"bar"',
+ 'limit' => 3,
+ 'page' => 'WzEuMl0=',
+ ],
+ null,
+ false,
+ [
+ 'object' => 'search_result',
+ 'data' => [['id' => '2'], ['id' => '3']],
+ 'has_more' => false,
+ ]
+ );
+
+ $this->fixture->setFilters([
+ 'query' => 'metadata["foo"]:"bar"',
+ 'limit' => 3,
+ ]);
+
+ $nextPage = $this->fixture->nextPage();
+ $ids = [];
+ foreach ($nextPage->data as $element) {
+ $ids[] = $element['id'];
+ }
+ static::assertSame(['2', '3'], $ids);
+ }
+
+ public function testFirst()
+ {
+ $SearchResult = SearchResult::constructFrom([
+ 'data' => [
+ ['content' => 'first'],
+ ['content' => 'middle'],
+ ['content' => 'last'],
+ ],
+ ]);
+ static::assertSame('first', $SearchResult->first()['content']);
+ }
+
+ public function testLast()
+ {
+ $SearchResult = SearchResult::constructFrom([
+ 'data' => [
+ ['content' => 'first'],
+ ['content' => 'middle'],
+ ['content' => 'last'],
+ ],
+ ]);
+ static::assertSame('last', $SearchResult->last()['content']);
+ }
+}