From 7a5afc10bb1dd56d71baac8c4ffcfdb592c9991c Mon Sep 17 00:00:00 2001 From: Hypolite Petovan Date: Sun, 10 May 2020 01:09:20 -0400 Subject: [PATCH] Implement Twitter contact endpoints - Implement /followers/ids - Implement /followers/list - Reimplement /friends/ids - Reimplement /friends/list - Update API reference - Remove obsolete tests --- doc/API-Twitter.md | 26 ++- include/api.php | 90 --------- src/Module/Api/Twitter/ContactEndpoint.php | 202 +++++++++++++++++++++ src/Module/Api/Twitter/FollowersIds.php | 49 +++++ src/Module/Api/Twitter/FollowersList.php | 51 ++++++ src/Module/Api/Twitter/FriendsIds.php | 49 +++++ src/Module/Api/Twitter/FriendsList.php | 51 ++++++ static/routes.config.php | 4 + tests/include/ApiTest.php | 55 ------ 9 files changed, 422 insertions(+), 155 deletions(-) create mode 100644 src/Module/Api/Twitter/ContactEndpoint.php create mode 100644 src/Module/Api/Twitter/FollowersIds.php create mode 100644 src/Module/Api/Twitter/FollowersList.php create mode 100644 src/Module/Api/Twitter/FriendsIds.php create mode 100644 src/Module/Api/Twitter/FriendsList.php diff --git a/doc/API-Twitter.md b/doc/API-Twitter.md index d352e528de..86a3178752 100644 --- a/doc/API-Twitter.md +++ b/doc/API-Twitter.md @@ -152,19 +152,27 @@ These endpoints use the [Friendica API entities](help/API-Entities). - [GET api/friendships/incoming](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friendships-incoming) - Unsupported parameters - `stringify_ids` -- [GET api/followers/ids](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-ids) - - Unsupported parameters: - - `user_id`: Relationships aren't returned for other users than self - - `screen_name`: Relationships aren't returned for other users than self -- [GET api/friends/ids](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-ids) - - Unsupported parameters: - - `user_id`: Relationships aren't returned for other users than self - - `screen_name`: Relationships aren't returned for other users than self + +- - [GET api/followers/ids](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-ids) + - [GET api/followers/list](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-list) + - [GET api/friends/ids](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-ids) + - [GET api/friends/list](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-list) + - Additional parameter: + - `since_id`: Same behavior as `cursor`, use the `next_cursor` value to load the next page. + - Unsupported parameter: + - `skip_status`: No status is returned even if it isn't set to true. + - Caveats: + - `user_id` must be the ID of a contact associated with a local user account. + - `screen_name` must be associated with a local user account. + - `screen_name` trumps `user_id` if both are provided (undocumented Twitter behavior). + - Will succeed but return an empty array for users hiding their contact lists. - [POST api/friendships/destroy](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/post-friendships-destroy) + + ## Non-implemented endpoints - [GET oauth/authenticate](https://developer.twitter.com/en/docs/basics/authentication/api-reference/authenticate) @@ -188,8 +196,6 @@ These endpoints use the [Friendica API entities](help/API-Entities). - [POST lists/subscribers/destroy](https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/post-lists-subscribers-destroy) -- [GET followers/list](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-list) -- [GET friends/list](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-list) - [GET friendships/lookup](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friendships-lookup) - [GET friendships/no_retweets/ids](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friendships-no_retweets-ids) - [GET friendships/outgoing](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friendships-outgoing) diff --git a/include/api.php b/include/api.php index 90d0a511b7..d6a1993bf7 100644 --- a/include/api.php +++ b/include/api.php @@ -3580,96 +3580,6 @@ function api_statusnet_version($type) api_register_func('api/gnusocial/version', 'api_statusnet_version', false); api_register_func('api/statusnet/version', 'api_statusnet_version', false); -/** - * - * @param string $type Return type (atom, rss, xml, json) - * - * @param int $rel A contact relationship constant - * @return array|string|void - * @throws BadRequestException - * @throws ForbiddenException - * @throws ImagickException - * @throws InternalServerErrorException - * @throws UnauthorizedException - * @todo use api_format_data() to return data - */ -function api_ff_ids($type, int $rel) -{ - if (!api_user()) { - throw new ForbiddenException(); - } - - $a = DI::app(); - - api_get_user($a); - - $stringify_ids = $_REQUEST['stringify_ids'] ?? false; - - $contacts = DBA::p("SELECT `pcontact`.`id` - FROM `contact` - INNER JOIN `contact` AS `pcontact` - ON `contact`.`nurl` = `pcontact`.`nurl` - AND `pcontact`.`uid` = 0 - WHERE `contact`.`uid` = ? - AND NOT `contact`.`self` - AND `contact`.`rel` IN (?, ?)", - api_user(), - $rel, - Contact::FRIEND - ); - - $ids = []; - foreach (DBA::toArray($contacts) as $contact) { - if ($stringify_ids) { - $ids[] = $contact['id']; - } else { - $ids[] = intval($contact['id']); - } - } - - return api_format_data('ids', $type, ['id' => $ids]); -} - -/** - * Returns the ID of every user the user is following. - * - * @param string $type Return type (atom, rss, xml, json) - * - * @return array|string - * @throws BadRequestException - * @throws ForbiddenException - * @throws ImagickException - * @throws InternalServerErrorException - * @throws UnauthorizedException - * @see https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-ids - */ -function api_friends_ids($type) -{ - return api_ff_ids($type, Contact::SHARING); -} - -/** - * Returns the ID of every user following the user. - * - * @param string $type Return type (atom, rss, xml, json) - * - * @return array|string - * @throws BadRequestException - * @throws ForbiddenException - * @throws ImagickException - * @throws InternalServerErrorException - * @throws UnauthorizedException - * @see https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-ids - */ -function api_followers_ids($type) -{ - return api_ff_ids($type, Contact::FOLLOWER); -} - -/// @TODO move to top of file or somewhere better -api_register_func('api/friends/ids', 'api_friends_ids', true); -api_register_func('api/followers/ids', 'api_followers_ids', true); - /** * Sends a new direct message. * diff --git a/src/Module/Api/Twitter/ContactEndpoint.php b/src/Module/Api/Twitter/ContactEndpoint.php new file mode 100644 index 0000000000..0e45488e1e --- /dev/null +++ b/src/Module/Api/Twitter/ContactEndpoint.php @@ -0,0 +1,202 @@ +. + * + */ + +namespace Friendica\Module\Api\Twitter; + +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Model\Profile; +use Friendica\Model\User; +use Friendica\Module\BaseApi; +use Friendica\Model\Contact; +use Friendica\Network\HTTPException; +use Friendica\Util\Strings; + +abstract class ContactEndpoint extends BaseApi +{ + const DEFAULT_COUNT = 20; + const MAX_COUNT = 200; + + public static function init(array $parameters = []) + { + parent::init($parameters); + + if (!self::login()) { + throw new HTTPException\UnauthorizedException(); + } + } + + /** + * Computes the uid from the contact_id + screen_name parameters + * + * @param int|null $contact_id + * @param string $screen_name + * @return int + * @throws HTTPException\NotFoundException + */ + protected static function getUid(int $contact_id = null, string $screen_name = null) + { + $uid = self::$current_user_id; + + if ($contact_id || $screen_name) { + // screen_name trumps user_id when both are provided + if (!$screen_name) { + $contact = Contact::getById($contact_id, ['nick', 'url']); + // We don't have the followers of remote accounts so we check for locality + if (empty($contact) || !Strings::startsWith($contact['url'], DI::baseUrl()->get())) { + throw new HTTPException\NotFoundException(DI::l10n()->t('Contact not found')); + } + + $screen_name = $contact['nick']; + } + + $user = User::getByNickname($screen_name, ['uid']); + if (empty($user)) { + throw new HTTPException\NotFoundException(DI::l10n()->t('User not found')); + } + + $uid = $user['uid']; + } + + return $uid; + } + + /** + * This methods expands the contact ids into full user objects in an existing result set. + * + * @param mixed $rel A relationship constant or a list of them + * @param int $uid The local user id we query the contacts from + * @param int $cursor + * @param int $count + * @param bool $skip_status + * @param bool $include_user_entities + * @return array + * @throws HTTPException\InternalServerErrorException + * @throws HTTPException\NotFoundException + * @throws \ImagickException + */ + protected static function list($rel, int $uid, int $cursor = -1, int $count = self::DEFAULT_COUNT, bool $skip_status = false, bool $include_user_entities = true) + { + $return = self::ids($rel, $uid, $cursor, $count); + + $users = []; + foreach ($return['ids'] as $contactId) { + $users[] = DI::twitterUser()->createFromContactId($contactId, $uid, $skip_status, $include_user_entities); + } + + unset($return['ids']); + $return['users'] = $users; + + $return = [ + 'users' => $users, + 'next_cursor' => $return['next_cursor'], + 'next_cursor_str' => $return['next_cursor_str'], + 'previous_cursor' => $return['previous_cursor'], + 'previous_cursor_str' => $return['previous_cursor_str'], + 'total_count' => $return['total_count'], + ]; + + + + return $return; + } + + /** + * @param mixed $rel A relationship constant or a list of them + * @param int $uid The local user id we query the contacts from + * @param int $cursor + * @param int $count + * @param bool $stringify_ids + * @return array + * @throws HTTPException\NotFoundException + */ + protected static function ids($rel, int $uid, int $cursor = -1, int $count = self::DEFAULT_COUNT, bool $stringify_ids = false) + { + $hide_friends = false; + if ($uid != self::$current_user_id) { + $profile = Profile::getByUID($uid); + if (empty($profile)) { + throw new HTTPException\NotFoundException(DI::l10n()->t('Profile not found')); + } + + $hide_friends = (bool)$profile['hide-friends']; + } + + $condition = DBA::collapseCondition([ + 'rel' => $rel, + 'uid' => $uid, + 'self' => false, + 'deleted' => false, + 'hidden' => false, + 'archive' => false, + 'pending' => false + ]); + + if ($cursor !== -1) { + $condition[0] .= " AND `id` > ?"; + $condition[] = $cursor; + } + + $ids = []; + $next_cursor = 0; + $previous_cursor = 0; + $total_count = 0; + if (!$hide_friends) { + $total_count = DBA::count('contact', $condition); + + $contacts = Contact::selectToArray(['id'], $condition, ['limit' => $count, 'order' => ['id']]); + + // Contains user-specific contact ids + $ids = array_column($contacts, 'id'); + + // Cursor is on the user-specific contact id since it's the sort field + if (count($ids)) { + $next_cursor = $ids[count($ids) -1]; + } + + // Conversion to public contact ids + array_walk($ids, function (&$contactId) use ($uid, $stringify_ids) { + $cdata = Contact::getPublicAndUserContacID($contactId, $uid); + if ($stringify_ids) { + $contactId = (string)$cdata['public']; + } else { + $contactId = (int)$cdata['public']; + } + }); + + // No next page + if ($total_count <= count($contacts)) { + $next_cursor = 0; + } + } + + $return = [ + 'ids' => $ids, + 'next_cursor' => $next_cursor, + 'next_cursor_str' => (string)$next_cursor, + 'previous_cursor' => $previous_cursor, + 'previous_cursor_str' => (string)$previous_cursor, + 'total_count' => $total_count, + ]; + + return $return; + } +} diff --git a/src/Module/Api/Twitter/FollowersIds.php b/src/Module/Api/Twitter/FollowersIds.php new file mode 100644 index 0000000000..8e0a7da98b --- /dev/null +++ b/src/Module/Api/Twitter/FollowersIds.php @@ -0,0 +1,49 @@ +. + * + */ + +namespace Friendica\Module\Api\Twitter; + +use Friendica\Core\System; +use Friendica\Model\Contact; + +/** + * @see https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-ids + */ +class FollowersIds extends ContactEndpoint +{ + public static function rawContent(array $parameters = []) + { + // Expected value for user_id parameter: public/user contact id + $contact_id = $_GET['user_id'] ?? null; + $screen_name = $_GET['screen_name'] ?? null; + $cursor = $_GET['cursor'] ?? $_GET['since_id'] ?? -1; + $stringify_ids = ($_GET['stringify_ids'] ?? 'false') != 'false'; + $count = min((int) ($_GET['count'] ?? self::DEFAULT_COUNT), self::MAX_COUNT); + + System::jsonExit(self::ids( + [Contact::FOLLOWER, Contact::FRIEND], + self::getUid($contact_id, $screen_name), + $cursor, + $count, + $stringify_ids + )); + } +} diff --git a/src/Module/Api/Twitter/FollowersList.php b/src/Module/Api/Twitter/FollowersList.php new file mode 100644 index 0000000000..867272bfba --- /dev/null +++ b/src/Module/Api/Twitter/FollowersList.php @@ -0,0 +1,51 @@ +. + * + */ + +namespace Friendica\Module\Api\Twitter; + +use Friendica\Core\System; +use Friendica\Model\Contact; + +/** + * @see https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-list + */ +class FollowersList extends ContactEndpoint +{ + public static function rawContent(array $parameters = []) + { + // Expected value for user_id parameter: public/user contact id + $contact_id = $_GET['user_id'] ?? null; + $screen_name = $_GET['screen_name'] ?? null; + $cursor = $_GET['cursor'] ?? $_GET['since_id'] ?? -1; + $count = min((int) ($_GET['count'] ?? self::DEFAULT_COUNT), self::MAX_COUNT); + $skip_status = in_array(($_GET['skip_status'] ?? false), [true, 'true', 't', 1, '1']); + $include_user_entities = ($_GET['include_user_entities'] ?? 'true') != 'false'; + + System::jsonExit(self::list( + [Contact::FOLLOWER, Contact::FRIEND], + self::getUid($contact_id, $screen_name), + $cursor, + $count, + $skip_status, + $include_user_entities + )); + } +} diff --git a/src/Module/Api/Twitter/FriendsIds.php b/src/Module/Api/Twitter/FriendsIds.php new file mode 100644 index 0000000000..4086b17a50 --- /dev/null +++ b/src/Module/Api/Twitter/FriendsIds.php @@ -0,0 +1,49 @@ +. + * + */ + +namespace Friendica\Module\Api\Twitter; + +use Friendica\Core\System; +use Friendica\Model\Contact; + +/** + * @see https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-ids + */ +class FriendsIds extends ContactEndpoint +{ + public static function rawContent(array $parameters = []) + { + // Expected value for user_id parameter: public/user contact id + $contact_id = $_GET['user_id'] ?? null; + $screen_name = $_GET['screen_name'] ?? null; + $cursor = $_GET['cursor'] ?? $_GET['since_id'] ?? -1; + $stringify_ids = ($_GET['stringify_ids'] ?? 'false') != 'false'; + $count = min((int) ($_GET['count'] ?? self::DEFAULT_COUNT), self::MAX_COUNT); + + System::jsonExit(self::ids( + [Contact::SHARING, Contact::FRIEND], + self::getUid($contact_id, $screen_name), + $cursor, + $count, + $stringify_ids + )); + } +} diff --git a/src/Module/Api/Twitter/FriendsList.php b/src/Module/Api/Twitter/FriendsList.php new file mode 100644 index 0000000000..e07f558f1d --- /dev/null +++ b/src/Module/Api/Twitter/FriendsList.php @@ -0,0 +1,51 @@ +. + * + */ + +namespace Friendica\Module\Api\Twitter; + +use Friendica\Core\System; +use Friendica\Model\Contact; + +/** + * @see https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-list + */ +class FriendsList extends ContactEndpoint +{ + public static function rawContent(array $parameters = []) + { + // Expected value for user_id parameter: public/user contact id + $contact_id = $_GET['user_id'] ?? null; + $screen_name = $_GET['screen_name'] ?? null; + $cursor = $_GET['cursor'] ?? $_GET['since_id'] ?? -1; + $count = min((int) ($_GET['count'] ?? self::DEFAULT_COUNT), self::MAX_COUNT); + $skip_status = in_array(($_GET['skip_status'] ?? false), [true, 'true', 't', 1, '1']); + $include_user_entities = ($_GET['include_user_entities'] ?? 'true') != 'false'; + + System::jsonExit(self::list( + [Contact::SHARING, Contact::FRIEND], + self::getUid($contact_id, $screen_name), + $cursor, + $count, + $skip_status, + $include_user_entities + )); + } +} diff --git a/static/routes.config.php b/static/routes.config.php index 9fbe358830..074c1f5710 100644 --- a/static/routes.config.php +++ b/static/routes.config.php @@ -57,6 +57,10 @@ return [ '/profile/show' => [Module\Api\Friendica\Profile\Show::class , [R::GET ]], '/events' => [Module\Api\Friendica\Events\Index::class , [R::GET ]], ], + '/followers/ids' => [Module\Api\Twitter\FollowersIds::class , [R::GET ]], + '/followers/list' => [Module\Api\Twitter\FollowersList::class , [R::GET ]], + '/friends/ids' => [Module\Api\Twitter\FriendsIds::class , [R::GET ]], + '/friends/list' => [Module\Api\Twitter\FriendsList::class , [R::GET ]], ], '/admin' => [ diff --git a/tests/include/ApiTest.php b/tests/include/ApiTest.php index d6ce7576fe..e12b0cdd7d 100644 --- a/tests/include/ApiTest.php +++ b/tests/include/ApiTest.php @@ -2851,61 +2851,6 @@ class ApiTest extends DatabaseTest $this->assertEquals('0.9.7', $result['version']); } - /** - * Test the api_ff_ids() function. - * - * @return void - */ - public function testApiFfIds() - { - $result = api_ff_ids('json', Contact::FOLLOWER); - $this->assertEquals(['id' => []], $result); - } - - /** - * Test the api_ff_ids() function with a result. - * - * @return void - */ - public function testApiFfIdsWithResult() - { - $this->markTestIncomplete(); - } - - /** - * Test the api_ff_ids() function without an authenticated user. - * - * @return void - * @expectedException Friendica\Network\HTTPException\ForbiddenException - */ - public function testApiFfIdsWithoutAuthenticatedUser() - { - $_SESSION['authenticated'] = false; - api_ff_ids('json', Contact::FOLLOWER); - } - - /** - * Test the api_friends_ids() function. - * - * @return void - */ - public function testApiFriendsIds() - { - $result = api_friends_ids('json'); - $this->assertEquals(['id' => []], $result); - } - - /** - * Test the api_followers_ids() function. - * - * @return void - */ - public function testApiFollowersIds() - { - $result = api_followers_ids('json'); - $this->assertEquals(['id' => []], $result); - } - /** * Test the api_direct_messages_new() function. *