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']); + } +}