Skip to content
This repository has been archived by the owner on Jan 21, 2020. It is now read-only.

Allows the *Lookup methods to accept arrays of identifiers or screen names #45

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 120 additions & 15 deletions src/Twitter.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

namespace ZendService\Twitter;

use Closure;
use Traversable;
use ZendOAuth as OAuth;
use Zend\Http;
Expand Down Expand Up @@ -803,14 +804,21 @@ public function friendshipsCreate($id, array $params = []) : Response
*
* Returns the next cursor if there are more to be returned.
*
* @param int|string $id
* $id may be one of any of the following:
*
* - a single user identifier
* - a single screen name
* - a list of user identifiers
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be split into a singular and a plural method (new API).
Widening the accepted types is also a BC break, as it breaks LSP compliance for subclasses.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it considered widening if no typehint is present, though?

Further, the method is a 1:1 map with the Twitter API, which has always allowed these values; this is a fix to ensure that we allow what the API allows.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, because the previous API was about a single identifier. The problem is child classes, not really the twitter API.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doing so would break the 1:1 relationship with the Twitter API, though. Additionally, the changes still support usage of singular identifiers.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand the point about parameter widening and child classes. However, an API wrapper like this is less likely to have somebody extend an existing method that maps to the API, and more likely to extend to add methods to interact with API endpoints that have not yet been implemented (e.g., new methods). The primary factor in this case is that the consumer API does not break, versus the extension API.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Breaking 1:1 naming is not a problem, but introducing mixed endpoints is.

The consumer doesn't break anyway: if they need to use multi-value APIs, they just use the specialised method.

This is a limitation of PHP itself, since method overloading isn't allowed.

Regardless of BC, adding an union type is a problem.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, as a consumer of this component, having the end points match the twitter API is better. The API does not differentiate between calling the loopkups with a single ID or multiple IDs. Not sure why the component should.

This would be a breaking BC as I actually have working code using these methods.

* - a list of screen names
*
* @param int|string|array $id
* @throws Http\Client\Exception\ExceptionInterface if HTTP request fails or times out
* @throws Exception\DomainException if unable to decode JSON payload
*/
public function friendshipsLookup($id, array $params = []) : Response
{
$path = 'friendships/lookup';
$params = $this->createUserParameter($id, $params);
$params = $this->createUserListParameter($id, $params, __METHOD__);
return $this->get($path, $params);
}

Expand Down Expand Up @@ -1335,15 +1343,21 @@ public function statusesUserTimeline(array $options = []) : Response
*
* This is the most effecient way of gathering bulk user data.
*
* @param int|string $id
* $id may be one of any of the following:
*
* - a single user identifier
* - a single screen name
* - a list of user identifiers
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as above

* - a list of screen names
*
* @param int|string|array $id
* @throws Http\Client\Exception\ExceptionInterface if HTTP request fails or times out
* @throws Exception\DomainException if unable to decode JSON payload
*/
public function usersLookup($id, array $params = []) : Response
{
$path = 'users/lookup';
// $params = $this->createUserParameter($id, $params);
$params['user_id'] = $id;
$params = $this->createUserListParameter($id, $params, __METHOD__);
return $this->post($path, $params);
}

Expand Down Expand Up @@ -1446,16 +1460,17 @@ public function init(string $path, Http\Client $client) : void
* returned verbatim. Otherwise, a zero is returned.
*
* @param string|int $int
* @return string|int
*/
protected function validInteger($int)
protected function validInteger($int) : int
{
if (is_int($int)) {
if (is_int($int) && $int > -1) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realized in reviewing tests that negative values aren't allowed, so I added this additional condition.

return $int;
}

if (is_string($int) && preg_match('/^(\d+)$/', $int)) {
return $int;
}

return 0;
}

Expand All @@ -1467,12 +1482,11 @@ protected function validInteger($int)
protected function validateScreenName(string $name) : string
{
if (! is_string($name) || ! preg_match('/^[a-zA-Z0-9_]{1,15}$/', $name)) {
throw new Exception\InvalidArgumentException(
'Screen name, "'
. $name
. '" should only contain alphanumeric characters and'
. ' underscores, and not exceed 15 characters.'
);
throw new Exception\InvalidArgumentException(sprintf(
'Screen name, "%s" should only contain alphanumeric characters and'
. ' underscores, and not exceed 15 characters.',
$name
));
}
return $name;
}
Expand Down Expand Up @@ -1513,15 +1527,106 @@ protected function performPost(string $method, $data, Http\Client $client) : Htt
*
* @param int|string $id
* @param array $params
* @throws Exception\InvalidArgumentException if the value is neither an integer nor a string
*/
protected function createUserParameter($id, array $params) : array
{
if ($this->validInteger($id)) {
if (! is_string($id) && ! is_int($id)) {
throw new Exception\InvalidArgumentException(sprintf(
'$id must be an integer or a string, received %s',
is_object($id) ? get_class($id) : gettype($id)
));
}

if (0 !== $this->validInteger($id)) {
$params['user_id'] = $id;
return $params;
}

if (! is_string($id)) {
throw new Exception\InvalidArgumentException(sprintf(
'$id must be an integer or a string, received %s',
gettype($id)
));
}

$params['screen_name'] = $this->validateScreenName($id);
return $params;
}

/**
* Prepares a list of identifiers for use with endpoints accepting lists of users.
*
* The various `lookup` endpoints allow passing either:
*
* - a single user identifier
* - a single screen name
* - a list of user identifiers
* - a list of screen names
*
* This method checks for each of these conditions. For scalar $ids, it
* proxies to {@link createUserParameter}. Otherwise, it checks to ensure
* that all values are of the same type. For identifiers, it then
* concatenates the values and returns them in the `user_id` parameter.
* For screen names, it validates them first, before concatenating and
* returning them via the `screen_name` parameter.
*
* @param int|string|array $ids
* @throws Exception\InvalidArgumentException for a non-int/string/array $ids value.
* @throws Exception\InvalidArgumentException if an array of $ids exceeds 100 items.
* @throws Exception\InvalidArgumentException if an array of $ids are not all of the same type.
* @throws Exception\InvalidArgumentException if any screen name element is invalid.
*/
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

private

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a potential extension point for developers extending the class, as there are other methods in the Twitter API that behave similarly; protected was chosen deliberately, to match the visibility of createUserParameter(), which is also protected to allow for extension.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They look ok to me. Once they are implemented (and the other PR as well, I'll plug it into my projects and see if anything breaks. :)

Cheers!
=C=

protected function createUserListParameter($ids, array $params, string $context) : array
{
if (! is_array($ids)) {
return $this->createUserParameter($ids, $params);
}

if (100 < count($ids)) {
throw new Exception\InvalidArgumentException(sprintf(
'Lists of identifier(s) or screen name(s) provided for %s; '
. 'must contain no more than 100 items. '
. 'Received %d',
$context,
count($ids)
));
}

$detectedType = array_reduce($ids, function ($detectedType, $id) {
if (false === $detectedType) {
return $detectedType;
}

$idType = 0 !== $this->validInteger($id)
? 'user_id'
: 'screen_name';

if (null === $detectedType) {
return $idType;
}

return $detectedType === $idType
? $detectedType
: false;
}, null);

if (false === $detectedType) {
throw new Exception\InvalidArgumentException(sprintf(
'Invalid identifier(s) or screen name(s) provided for %s; '
. 'all values must either be identifiers OR screen names. '
. 'You cannot provide items of both types.',
$context
));
}

if ($detectedType === 'user_id') {
$params['user_id'] = implode(',', $ids);
return $params;
}

array_walk($ids, Closure::fromCallable([$this, 'validateScreenName']));
$params['screen_name'] = implode(',', $ids);
return $params;
}
}
95 changes: 95 additions & 0 deletions test/TwitterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1130,4 +1130,99 @@ public function testListsMembersRaisesExceptionIfSlugPassedWithInvalidOwnerScree
$this->expectExceptionMessage('invalid owner_screen_name');
$twitter->lists->members('zendframework', ['owner_screen_name' => $owner]);
}

public function userIdentifierProvider() : iterable
{
yield 'single-user_id' => [111, 'user_id'];
yield 'single-screen_name' => ['zfdevteam', 'screen_name'];
yield 'multi-user_id' => [range(100, 150), 'user_id'];
yield 'multi-screen_name' => [range('a', 'z'), 'screen_name'];
}

/**
* @dataProvider userIdentifierProvider
* @param mixed $id
*/
public function testUsersLookupAcceptsAllSupportedUserIdentifiers($id, string $paramKey)
{
$expected = is_array($id)
? $expected = implode(',', $id)
: $id;

$twitter = new Twitter\Twitter();
$twitter->setHttpClient($this->stubOAuthClient(
'users/lookup.json',
Http\Request::METHOD_POST,
'users.lookup.json',
[$paramKey => $expected]
));

$finalResponse = $twitter->users->lookup($id);
$this->assertInstanceOf(TwitterResponse::class, $finalResponse);
}

/**
* @dataProvider userIdentifierProvider
* @param mixed $id
*/
public function testFriendshipsLookupAcceptsAllSupportedUserIdentifiers($id, string $paramKey)
{
$expected = is_array($id)
? $expected = implode(',', $id)
: $id;

$twitter = new Twitter\Twitter();
$twitter->setHttpClient($this->stubOAuthClient(
'friendships/lookup.json',
Http\Request::METHOD_GET,
'friendships.lookup.json',
[$paramKey => $expected]
));

$finalResponse = $twitter->friendships->lookup($id);
$this->assertInstanceOf(TwitterResponse::class, $finalResponse);
}

public function invalidUserIdentifierProvider() : iterable
{
yield 'null' => [null, 'integer or a string'];
yield 'true' => [true, 'integer or a string'];
yield 'false' => [false, 'integer or a string'];
yield 'zero-float' => [0.0, 'integer or a string'];
yield 'float' => [1.1, 'integer or a string'];
yield 'empty-string' => ['', 'alphanumeric'];
yield 'malformed-string' => ['not a valid screen name', 'alphanumeric'];
yield 'array-too-many' => [range(1, 200), 'no more than 100'];
yield 'array-type-mismatch' => [[1, 'screenname'], 'identifiers OR screen names'];
yield 'array-invalid-screen-name' => [['not a valid screen name'], 'alphanumeric'];
yield 'object' => [new stdClass(), 'integer or a string'];
}

/**
* @dataProvider invalidUserIdentifierProvider
* @param mixed $ids
*/
public function testUsersLookupRaisesExceptionIfInvalidIdentifiersProvided($ids, string $expectedMessage)
{
$twitter = new Twitter\Twitter();

$this->expectException(Twitter\Exception\InvalidArgumentException::class);
$this->expectExceptionMessage($expectedMessage);

$twitter->users->lookup($ids);
}

/**
* @dataProvider invalidUserIdentifierProvider
* @param mixed $ids
*/
public function testFriendshipsLookupRaisesExceptionIfInvalidIdentifiersProvided($ids, string $expectedMessage)
{
$twitter = new Twitter\Twitter();

$this->expectException(Twitter\Exception\InvalidArgumentException::class);
$this->expectExceptionMessage($expectedMessage);

$twitter->friendships->lookup($ids);
}
}
41 changes: 41 additions & 0 deletions test/_files/friendships.lookup.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
[
{
"name": "Taylor Singletary",
"screen_name": "episod",
"id": 819797,
"id_str": "819797",
"connections": [
"following",
"followed_by"
]
},
{
"name": "Twitter API",
"screen_name": "twitterapi",
"id": 6253282,
"id_str": "6253282",
"connections": [
"following",
"followed_by"
]
},
{
"name": "White Leaf",
"screen_name": "whiteleaf",
"id": 22479443,
"id_str": "22479443",
"connections": [
"followed_by",
"muting"
]
},
{
"name": "Andy Piper",
"screen_name": "andypiper",
"id": 786491,
"id_str": "786491",
"connections": [
"none"
]
}
]
Loading