Browse Source

Merge pull request #8617 from MrPetovan/task/8220-twitter-followers-list

(Re)Implement Twitter contact API endpoints
develop
Michael Vogel GitHub 3 weeks ago
parent
commit
f10062dfdb
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1082 additions and 194 deletions
  1. +18
    -10
      doc/API-Twitter.md
  2. +0
    -90
      include/api.php
  3. +8
    -0
      src/DI.php
  4. +55
    -0
      src/Factory/Api/Twitter/User.php
  5. +223
    -0
      src/Module/Api/Twitter/ContactEndpoint.php
  6. +58
    -0
      src/Module/Api/Twitter/FollowersIds.php
  7. +62
    -0
      src/Module/Api/Twitter/FollowersList.php
  8. +58
    -0
      src/Module/Api/Twitter/FriendsIds.php
  9. +61
    -0
      src/Module/Api/Twitter/FriendsList.php
  10. +153
    -0
      src/Object/Api/Twitter/User.php
  11. +4
    -0
      static/routes.config.php
  12. +53
    -0
      tests/FixtureTest.php
  13. +39
    -0
      tests/datasets/api.fixture.php
  14. +14
    -94
      tests/include/ApiTest.php
  15. +22
    -0
      tests/src/Module/Api/Twitter/ContactEndpointMock.php
  16. +254
    -0
      tests/src/Module/Api/Twitter/ContactEndpointTest.php

+ 18
- 10
doc/API-Twitter.md View File

@@ -152,19 +152,29 @@ 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 parameters:
- `since_id`: You can use the `next_cursor` value to load the next page.
- `max_id`: You can use the inverse of the `previous_cursor` value to load the previous page.
- Unsupported parameter:
- `skip_status`: No status is returned even if it isn't set to true.
- Caveats:
- `cursor` trumps `since_id` trumps `max_id` if any combination is provided.
- `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 +198,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)


+ 0
- 90
include/api.php View File

@@ -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.
*


+ 8
- 0
src/DI.php View File

@@ -279,6 +279,14 @@ abstract class DI
return self::$dice->create(Factory\Api\Mastodon\Relationship::class);
}

/**
* @return Factory\Api\Twitter\User
*/
public static function twitterUser()
{
return self::$dice->create(Factory\Api\Twitter\User::class);
}

/**
* @return Factory\Notification\Notification
*/


+ 55
- 0
src/Factory/Api/Twitter/User.php View File

@@ -0,0 +1,55 @@
<?php
/**
* @copyright Copyright (C) 2020, Friendica
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/

namespace Friendica\Factory\Api\Twitter;

use Friendica\BaseFactory;
use Friendica\Model\APContact;
use Friendica\Model\Contact;
use Friendica\Network\HTTPException;

class User extends BaseFactory
{
/**
* @param int $contactId
* @param int $uid Public contact (=0) or owner user id
* @param bool $skip_status
* @param bool $include_user_entities
* @return \Friendica\Object\Api\Twitter\User
* @throws HTTPException\InternalServerErrorException
* @throws \ImagickException
*/
public function createFromContactId(int $contactId, $uid = 0, $skip_status = false, $include_user_entities = true)
{
$cdata = Contact::getPublicAndUserContacID($contactId, $uid);
if (!empty($cdata)) {
$publicContact = Contact::getById($cdata['public']);
$userContact = Contact::getById($cdata['user']);
} else {
$publicContact = Contact::getById($contactId);
$userContact = [];
}

$apcontact = APContact::getByURL($publicContact['url'], false);

return new \Friendica\Object\Api\Twitter\User($publicContact, $apcontact, $userContact, $skip_status, $include_user_entities);
}
}

+ 223
- 0
src/Module/Api/Twitter/ContactEndpoint.php View File

@@ -0,0 +1,223 @@
<?php
/**
* @copyright Copyright (C) 2020, Friendica
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/

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

$ids = [];
$next_cursor = 0;
$previous_cursor = 0;
$total_count = 0;
if (!$hide_friends) {
$condition = DBA::collapseCondition([
'rel' => $rel,
'uid' => $uid,
'self' => false,
'deleted' => false,
'hidden' => false,
'archive' => false,
'pending' => false
]);

$total_count = DBA::count('contact', $condition);

if ($cursor !== -1) {
if ($cursor > 0) {
$condition[0] .= " AND `id` > ?";
$condition[] = $cursor;
} else {
$condition[0] .= " AND `id` < ?";
$condition[] = -$cursor;
}
}

$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)) {
$previous_cursor = -$ids[0];
$next_cursor = $ids[count($ids) -1];
}

// No next page
if ($total_count <= count($contacts) || count($contacts) < $count) {
$next_cursor = 0;
}
// End of results
if ($cursor < 0 && count($contacts) === 0) {
$next_cursor = -1;
}

// No previous page
if ($cursor === -1) {
$previous_cursor = 0;
}

if ($cursor > 0 && count($contacts) === 0) {
$previous_cursor = -$cursor;
}

if ($cursor < 0 && count($contacts) === 0) {
$next_cursor = -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'];
}
});
}

$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;
}
}

+ 58
- 0
src/Module/Api/Twitter/FollowersIds.php View File

@@ -0,0 +1,58 @@
<?php
/**
* @copyright Copyright (C) 2020, Friendica
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/

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 = filter_input(INPUT_GET, 'user_id' , FILTER_VALIDATE_INT);
$screen_name = filter_input(INPUT_GET, 'screen_name');
$cursor = filter_input(INPUT_GET, 'cursor' , FILTER_VALIDATE_INT);
$stringify_ids = filter_input(INPUT_GET, 'stringify_ids', FILTER_VALIDATE_BOOLEAN);
$count = filter_input(INPUT_GET, 'count' , FILTER_VALIDATE_INT, ['options' => [
'default' => self::DEFAULT_COUNT,
'min_range' => 1,
'max_range' => self::MAX_COUNT,
]]);
// Friendica-specific
$since_id = filter_input(INPUT_GET, 'since_id' , FILTER_VALIDATE_INT);
$max_id = filter_input(INPUT_GET, 'max_id' , FILTER_VALIDATE_INT, ['options' => [
'default' => 1,
]]);

System::jsonExit(self::ids(
[Contact::FOLLOWER, Contact::FRIEND],
self::getUid($contact_id, $screen_name),
$cursor ?? $since_id ?? - $max_id,
$count,
$stringify_ids
));
}
}

+ 62
- 0
src/Module/Api/Twitter/FollowersList.php View File

@@ -0,0 +1,62 @@
<?php
/**
* @copyright Copyright (C) 2020, Friendica
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/

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 = filter_input(INPUT_GET, 'user_id' , FILTER_VALIDATE_INT);
$screen_name = filter_input(INPUT_GET, 'screen_name');
$cursor = filter_input(INPUT_GET, 'cursor' , FILTER_VALIDATE_INT);
$count = filter_input(INPUT_GET, 'count' , FILTER_VALIDATE_INT, ['options' => [
'default' => self::DEFAULT_COUNT,
'min_range' => 1,
'max_range' => self::MAX_COUNT,
]]);
$skip_status = filter_input(INPUT_GET, 'skip_status' , FILTER_VALIDATE_BOOLEAN);
$include_user_entities = filter_input(INPUT_GET, 'include_user_entities', FILTER_VALIDATE_BOOLEAN);

// Friendica-specific
$since_id = filter_input(INPUT_GET, 'since_id' , FILTER_VALIDATE_INT);
$max_id = filter_input(INPUT_GET, 'max_id' , FILTER_VALIDATE_INT, ['options' => [
'default' => 1,
]]);


System::jsonExit(self::list(
[Contact::FOLLOWER, Contact::FRIEND],
self::getUid($contact_id, $screen_name),
$cursor ?? $since_id ?? - $max_id,
$count,
$skip_status,
$include_user_entities
));
}
}

+ 58
- 0
src/Module/Api/Twitter/FriendsIds.php View File

@@ -0,0 +1,58 @@
<?php
/**
* @copyright Copyright (C) 2020, Friendica
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/

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 = filter_input(INPUT_GET, 'user_id' , FILTER_VALIDATE_INT);
$screen_name = filter_input(INPUT_GET, 'screen_name');
$cursor = filter_input(INPUT_GET, 'cursor' , FILTER_VALIDATE_INT);
$stringify_ids = filter_input(INPUT_GET, 'stringify_ids', FILTER_VALIDATE_BOOLEAN);
$count = filter_input(INPUT_GET, 'count' , FILTER_VALIDATE_INT, ['options' => [
'default' => self::DEFAULT_COUNT,
'min_range' => 1,
'max_range' => self::MAX_COUNT,
]]);
// Friendica-specific
$since_id = filter_input(INPUT_GET, 'since_id' , FILTER_VALIDATE_INT);
$max_id = filter_input(INPUT_GET, 'max_id' , FILTER_VALIDATE_INT, ['options' => [
'default' => 1,
]]);

System::jsonExit(self::ids(
[Contact::SHARING, Contact::FRIEND],
self::getUid($contact_id, $screen_name),
$cursor ?? $since_id ?? - $max_id,
$count,
$stringify_ids
));
}
}

+ 61
- 0
src/Module/Api/Twitter/FriendsList.php View File

@@ -0,0 +1,61 @@
<?php
/**
* @copyright Copyright (C) 2020, Friendica
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/

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 = filter_input(INPUT_GET, 'user_id' , FILTER_VALIDATE_INT);
$screen_name = filter_input(INPUT_GET, 'screen_name');
$cursor = filter_input(INPUT_GET, 'cursor' , FILTER_VALIDATE_INT);
$count = filter_input(INPUT_GET, 'count' , FILTER_VALIDATE_INT, ['options' => [
'default' => self::DEFAULT_COUNT,
'min_range' => 1,
'max_range' => self::MAX_COUNT,
]]);
$skip_status = filter_input(INPUT_GET, 'skip_status' , FILTER_VALIDATE_BOOLEAN);
$include_user_entities = filter_input(INPUT_GET, 'include_user_entities', FILTER_VALIDATE_BOOLEAN);

// Friendica-specific
$since_id = filter_input(INPUT_GET, 'since_id' , FILTER_VALIDATE_INT);
$max_id = filter_input(INPUT_GET, 'max_id' , FILTER_VALIDATE_INT, ['options' => [
'default' => 1,
]]);

System::jsonExit(self::list(
[Contact::SHARING, Contact::FRIEND],
self::getUid($contact_id, $screen_name),
$cursor ?? $since_id ?? - $max_id,
$count,
$skip_status,
$include_user_entities
));
}
}

+ 153
- 0
src/Object/Api/Twitter/User.php View File

@@ -0,0 +1,153 @@
<?php
/**
* @copyright Copyright (C) 2020, Friendica
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/

namespace Friendica\Object\Api\Twitter;

use Friendica\BaseEntity;
use Friendica\Content\ContactSelector;
use Friendica\Content\Text\BBCode;

/**
* @see https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/user-object
*/
class User extends BaseEntity
{
/** @var int */
protected $id;
/** @var string */
protected $id_str;
/** @var string */
protected $name;
/** @var string */
protected $screen_name;
/** @var string|null */
protected $location;
/** @var array */
protected $derived;
/** @var string|null */
protected $url;
/** @var array */
protected $entities;
/** @var string|null */
protected $description;
/** @var bool */
protected $protected;
/** @var bool */
protected $verified;
/** @var int */
protected $followers_count;
/** @var int */
protected $friends_count;
/** @var int */
protected $listed_count;
/** @var int */
protected $favourites_count;
/** @var int */
protected $statuses_count;
/** @var string */
protected $created_at;
/** @var string */
protected $profile_banner_url;
/** @var string */
protected $profile_image_url_https;
/** @var bool */
protected $default_profile;
/** @var bool */
protected $default_profile_image;
/** @var Status */
protected $status;
/** @var array */
protected $withheld_in_countries;
/** @var string */
protected $withheld_scope;

/**
* @param array $publicContact Full contact table record with uid = 0
* @param array $apcontact Optional full apcontact table record
* @param array $userContact Optional full contact table record with uid != 0
* @param bool $skip_status Whether to remove the last status property, currently unused
* @param bool $include_user_entities Whether to add the entities property
* @throws \Friendica\Network\HTTPException\InternalServerErrorException
*/
public function __construct(array $publicContact, array $apcontact = [], array $userContact = [], $skip_status = false, $include_user_entities = true)
{
$this->id = $publicContact['id'];
$this->id_str = (string) $publicContact['id'];
$this->name = $publicContact['name'];
$this->screen_name = $publicContact['nick'] ?: $publicContact['name'];
$this->location = $publicContact['location'] ?:
ContactSelector::networkToName($publicContact['network'], $publicContact['url'], $publicContact['protocol']);
$this->derived = [];
$this->url = $publicContact['url'];
// No entities needed since we don't perform any shortening in the URL or description
$this->entities = [
'url' => ['urls' => []],
'description' => ['urls' => []],
];
if (!$include_user_entities) {
unset($this->entities);
}
$this->description = BBCode::toPlaintext($publicContact['about']);
$this->profile_image_url_https = $userContact['avatar'] ?? $publicContact['avatar'];
$this->protected = false;
$this->followers_count = $apcontact['followers_count'] ?? 0;
$this->friends_count = $apcontact['following_count'] ?? 0;
$this->listed_count = 0;
$this->created_at = api_date($publicContact['created']);
$this->favourites_count = 0;
$this->verified = false;
$this->statuses_count = $apcontact['statuses_count'] ?? 0;
$this->profile_banner_url = '';
$this->default_profile = false;
$this->default_profile_image = false;

// @TODO Replace skip_status parameter with an optional Status parameter
unset($this->status);

// Unused optional fields
unset($this->withheld_in_countries);
unset($this->withheld_scope);

// Deprecated
$this->profile_image_url = $userContact['avatar'] ?? $publicContact['avatar'];
$this->profile_image_url_profile_size = $publicContact['thumb'];
$this->profile_image_url_large = $publicContact['photo'];
$this->utc_offset = 0;
$this->time_zone = 'UTC';
$this->geo_enabled = false;
$this->lang = null;
$this->contributors_enabled = false;
$this->is_translator = false;
$this->is_translation_enabled = false;
$this->following = false;
$this->follow_request_sent = false;
$this->statusnet_blocking = false;
$this->notifications = false;

// Friendica-specific
$this->uid = $userContact['uid'] ?? 0;
$this->cid = $userContact['id'] ?? 0;
$this->pid = $publicContact['id'];
$this->self = $userContact['self'] ?? false;
$this->network = $publicContact['network'];
$this->statusnet_profile_url = $publicContact['url'];
}
}

+ 4
- 0
static/routes.config.php View File

@@ -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' => [


+ 53
- 0
tests/FixtureTest.php View File

@@ -0,0 +1,53 @@
<?php
/**
* FixtureTest class.
*/

namespace Friendica\Test;

use Dice\Dice;
use Friendica\Core\Config\Cache;
use Friendica\Core\Config\IConfig;
use Friendica\Core\Session;
use Friendica\Core\Session\ISession;
use Friendica\Database\Database;
use Friendica\Database\DBStructure;
use Friendica\DI;
use Friendica\Test\Util\Database\StaticDatabase;

/**
* Parent class for test cases requiring fixtures
*/
abstract class FixtureTest extends DatabaseTest
{
/** @var Dice */
protected $dice;

/**
* Create variables used by tests.
*/
protected function setUp()
{
parent::setUp();

$this->dice = (new Dice())
->addRules(include __DIR__ . '/../static/dependencies.config.php')
->addRule(Database::class, ['instanceOf' => StaticDatabase::class, 'shared' => true])
->addRule(ISession::class, ['instanceOf' => Session\Memory::class, 'shared' => true, 'call' => null]);
DI::init($this->dice);

/** @var IConfig $config */
$configCache = $this->dice->create(Cache::class);
$configCache->set('database', 'disable_pdo', true);

/** @var Database $dba */
$dba = $this->dice->create(Database::class);

$dba->setTestmode(true);

DBStructure::checkInitialValues();

// Load the API dataset for the whole API
$this->loadFixture(__DIR__ . '/datasets/api.fixture.php', $dba);
}
}

+ 39
- 0
tests/datasets/api.fixture.php View File

@@ -99,6 +99,45 @@ return [
'rel' => 2,
'network' => 'dfrn',
],
[
'id' => 45,
'uid' => 0,
'name' => 'Friend contact',
'nick' => 'friendcontact',
'self' => 0,
'nurl' => 'http://localhost/profile/friendcontact',
'url' => 'http://localhost/profile/friendcontact',
'pending' => 0,
'blocked' => 0,
'rel' => 2,
'network' => 'dfrn',
],
[
'id' => 46,
'uid' => 42,
'name' => 'Mutual contact',
'nick' => 'mutualcontact',
'self' => 0,
'nurl' => 'http://localhost/profile/mutualcontact',
'url' => 'http://localhost/profile/mutualcontact',
'pending' => 0,
'blocked' => 0,
'rel' => 3,
'network' => 'dfrn',
],
[
'id' => 47,
'uid' => 0,
'name' => 'Mutual contact',
'nick' => 'mutualcontact',
'self' => 0,
'nurl' => 'http://localhost/profile/mutualcontact',
'url' => 'http://localhost/profile/mutualcontact',
'pending' => 0,
'blocked' => 0,
'rel' => 2,
'network' => 'dfrn',
],
],
'item-uri' => [
[


+ 14
- 94
tests/include/ApiTest.php View File

@@ -5,19 +5,12 @@

namespace Friendica\Test;

use Dice\Dice;
use Friendica\App;
use Friendica\Core\Config\IConfig;
use Friendica\Core\PConfig\IPConfig;
use Friendica\Core\Protocol;
use Friendica\Core\Session;
use Friendica\Core\Session\ISession;
use Friendica\Database\Database;
use Friendica\Database\DBStructure;
use Friendica\DI;
use Friendica\Model\Contact;
use Friendica\Network\HTTPException;
use Friendica\Test\Util\Database\StaticDatabase;
use Friendica\Util\Temporal;
use Monolog\Handler\TestHandler;

@@ -29,7 +22,7 @@ require_once __DIR__ . '/../../include/api.php';
* Functions that use header() need to be tested in a separate process.
* @see https://phpunit.de/manual/5.7/en/appendixes.annotations.html#appendixes.annotations.runTestsInSeparateProcesses
*/
class ApiTest extends DatabaseTest
class ApiTest extends FixtureTest
{
/**
* @var TestHandler Can handle log-outputs
@@ -51,9 +44,6 @@ class ApiTest extends DatabaseTest
/** @var IConfig */
protected $config;

/** @var Dice */
protected $dice;

/**
* Create variables used by tests.
*/
@@ -61,19 +51,6 @@ class ApiTest extends DatabaseTest
{
parent::setUp();

$this->dice = (new Dice())
->addRules(include __DIR__ . '/../../static/dependencies.config.php')
->addRule(Database::class, ['instanceOf' => StaticDatabase::class, 'shared' => true])
->addRule(ISession::class, ['instanceOf' => Session\Memory::class, 'shared' => true, 'call' => null]);
DI::init($this->dice);

/** @var Database $dba */
$dba = $this->dice->create(Database::class);

$dba->setTestmode(true);

DBStructure::checkInitialValues();

/** @var IConfig $config */
$this->config = $this->dice->create(IConfig::class);

@@ -88,8 +65,6 @@ class ApiTest extends DatabaseTest
$this->config->set('system', 'throttle_limit_month', 100);
$this->config->set('system', 'theme', 'system_theme');

// Load the API dataset for the whole API
$this->loadFixture(__DIR__ . '/../datasets/api.fixture.php', $dba);

/** @var App app */
$this->app = DI::app();
@@ -842,38 +817,38 @@ class ApiTest extends DatabaseTest
}

/**
* Test the api_get_user() function with a custom Frio schema.
* Test the api_get_user() function with an empty Frio schema.
*
* @return void
*/
public function testApiGetUserWithCustomFrioSchema()
public function testApiGetUserWithEmptyFrioSchema()
{
$pConfig = $this->dice->create(IPConfig::class);
$pConfig->set($this->selfUser['id'], 'frio', 'schema', '---');
$pConfig->set($this->selfUser['id'], 'frio', 'nav_bg', '#123456');
$pConfig->set($this->selfUser['id'], 'frio', 'link_color', '#123456');
$pConfig->set($this->selfUser['id'], 'frio', 'background_color', '#123456');
$user = api_get_user($this->app);
$this->assertSelfUser($user);
$this->assertEquals('123456', $user['profile_sidebar_fill_color']);
$this->assertEquals('123456', $user['profile_link_color']);
$this->assertEquals('123456', $user['profile_background_color']);
$this->assertEquals('708fa0', $user['profile_sidebar_fill_color']);
$this->assertEquals('6fdbe8', $user['profile_link_color']);
$this->assertEquals('ededed', $user['profile_background_color']);
}

/**
* Test the api_get_user() function with an empty Frio schema.
* Test the api_get_user() function with a custom Frio schema.
*
* @return void
*/
public function testApiGetUserWithEmptyFrioSchema()
public function testApiGetUserWithCustomFrioSchema()
{
$pConfig = $this->dice->create(IPConfig::class);
$pConfig->set($this->selfUser['id'], 'frio', 'schema', '---');
$pConfig->set($this->selfUser['id'], 'frio', 'nav_bg', '#123456');
$pConfig->set($this->selfUser['id'], 'frio', 'link_color', '#123456');
$pConfig->set($this->selfUser['id'], 'frio', 'background_color', '#123456');
$user = api_get_user($this->app);
$this->assertSelfUser($user);
$this->assertEquals('708fa0', $user['profile_sidebar_fill_color']);
$this->assertEquals('6fdbe8', $user['profile_link_color']);
$this->assertEquals('ededed', $user['profile_background_color']);
$this->assertEquals('123456', $user['profile_sidebar_fill_color']);
$this->assertEquals('123456', $user['profile_link_color']);
$this->assertEquals('123456', $user['profile_background_color']);
}

/**
@@ -2851,61 +2826,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.
*


+ 22
- 0
tests/src/Module/Api/Twitter/ContactEndpointMock.php View File

@@ -0,0 +1,22 @@
<?php

namespace Friendica\Test\src\Module\Api\Twitter;

/**
* Class ContactEndpointMock
*
* Exposes protected methods for test in the inherited class
*
* @method static int getUid(int $contact_id = null, string $screen_name = null)
* @method static array list($rel, int $uid, int $cursor = -1, int $count = self::DEFAULT_COUNT, bool $skip_status = false, bool $include_user_entities = true)
* @method static array ids($rel, int $uid, int $cursor = -1, int $count = self::DEFAULT_COUNT, bool $stringify_ids = false)
*
* @package Friendica\Test\Mock\Module\Api\Twitter
*/
class ContactEndpointMock extends \Friendica\Module\Api\Twitter\ContactEndpoint
{
public static function __callStatic($name, $arguments)
{
return self::$name(...$arguments);
}
}

+ 254
- 0
tests/src/Module/Api/Twitter/ContactEndpointTest.php View File

@@ -0,0 +1,254 @@
<?php

namespace Friendica\Test\src\Module\Api\Twitter;

use Friendica\Model\Contact;
use Friendica\Module\Api\Twitter\ContactEndpoint;
use Friendica\Network\HTTPException\NotFoundException;
use Friendica\Object\Api\Twitter\User;
use Friendica\Test\FixtureTest;

class ContactEndpointTest extends FixtureTest
{
public function testGetUid()
{
$this->assertSame(42, ContactEndpointMock::getUid(42));
$this->assertSame(42, ContactEndpointMock::getUid(null, 'selfcontact'));
$this->assertSame(42, ContactEndpointMock::getUid(84, 'selfcontact'));
}

public function testGetUidContactIdNotFound()
{
$this->expectException(NotFoundException::class);
$this->expectExceptionMessage('Contact not found');

ContactEndpointMock::getUid(84);
}

public function testGetUidScreenNameNotFound()
{
$this->expectException(NotFoundException::class);
$this->expectExceptionMessage('User not found');

ContactEndpointMock::getUid(null, 'othercontact');
}

public function testGetUidContactIdScreenNameNotFound()
{
$this->expectException(NotFoundException::class);
$this->expectExceptionMessage('User not found');

ContactEndpointMock::getUid(42, 'othercontact');
}

public function testIds()
{
$expectedEmpty = [
'ids' => [],
'next_cursor' => -1,
'next_cursor_str' => '-1',
'previous_cursor' => 0,
'previous_cursor_str' => '0',
'total_count' => 0,
];

$this->assertSame($expectedEmpty, ContactEndpointMock::ids(Contact::FOLLOWER, 42));

$expectedFriend = [
'ids' => [47],
'next_cursor' => 0,
'next_cursor_str' => '0',
'previous_cursor' => 0,
'previous_cursor_str' => '0',
'total_count' => 1,
];

$this->assertSame($expectedFriend, ContactEndpointMock::ids(Contact::FRIEND, 42));
$this->assertSame($expectedFriend, ContactEndpointMock::ids([Contact::FOLLOWER, Contact::FRIEND], 42));

$result = ContactEndpointMock::ids(Contact::SHARING, 42);

$this->assertArrayHasKey('ids', $result);
$this->assertContainsOnly('int', $result['ids']);
$this->assertSame(45, $result['ids'][0]);

$result = ContactEndpointMock::ids([Contact::SHARING, Contact::FRIEND], 42);

$this->assertArrayHasKey('ids', $result);
$this->assertContainsOnly('int', $result['ids']);
$this->assertSame(45, $result['ids'][0]);
}

/**
* @depends testIds
*
* @throws NotFoundException
*/
public function testIdsStringify()
{
$result = ContactEndpointMock::ids(Contact::SHARING, 42, -1, ContactEndpoint::DEFAULT_COUNT, true);

$this->assertArrayHasKey('ids', $result);
$this->assertContainsOnly('string', $result['ids']);
$this->assertSame('45', $result['ids'][0]);
}

public function testIdsPagination()
{
$expectedDefaultPageResult = [
'ids' => [45],
'next_cursor' => 44,
'next_cursor_str' => '44',
'previous_cursor' => 0,
'previous_cursor_str' => '0',
'total_count' => 2,
];

$result = ContactEndpointMock::ids([Contact::SHARING, Contact::FRIEND], 42, -1, 1);

$this->assertSame($expectedDefaultPageResult, $result);

$nextPageCursor = $result['next_cursor'];

$expectedSecondPageResult = [
'ids' => [47],
'next_cursor' => 46,
'next_cursor_str' => '46',
'previous_cursor' => -46,
'previous_cursor_str' => '-46',
'total_count' => 2,
];

$result = ContactEndpointMock::ids([Contact::SHARING, Contact::FRIEND], 42, $nextPageCursor, 1);

$this->assertSame($expectedSecondPageResult, $result);

$firstPageCursor = $result['previous_cursor'];
$emptyNextPageCursor = $result['next_cursor'];

$expectedFirstPageResult = [
'ids' => [45],
'next_cursor' => 44,
'next_cursor_str' => '44',
'previous_cursor' => -44,
'previous_cursor_str' => '-44',
'total_count' => 2,
];

$result = ContactEndpointMock::ids([Contact::SHARING, Contact::FRIEND], 42, $firstPageCursor, 1);

$this->assertSame($expectedFirstPageResult, $result);

$emptyPrevPageCursor = $result['previous_cursor'];

$expectedEmptyPrevPageResult = [
'ids' => [],
'next_cursor' => -1,
'next_cursor_str' => '-1',
'previous_cursor' => 0,
'previous_cursor_str' => '0',
'total_count' => 2,
];

$result = ContactEndpointMock::ids([Contact::SHARING, Contact::FRIEND], 42, $emptyPrevPageCursor, 1);

$this->assertSame($expectedEmptyPrevPageResult, $result);

$expectedEmptyNextPageResult = [
'ids' => [],
'next_cursor' => 0,
'next_cursor_str' => '0',
'previous_cursor' => -46,
'previous_cursor_str' => '-46',
'total_count' => 2,
];

$result = ContactEndpointMock::ids([Contact::SHARING, Contact::FRIEND], 42, $emptyNextPageCursor, 1);

$this->assertSame($expectedEmptyNextPageResult, $result);
}

/**
* @depends testIds
*
* @throws NotFoundException
* @throws \Friendica\Network\HTTPException\InternalServerErrorException
* @throws \ImagickException
*/
public function testList()
{
$expectedEmpty = [
'users' => [],
'next_cursor' => -1,
'next_cursor_str' => '-1',
'previous_cursor' => 0,
'previous_cursor_str' => '0',
'total_count' => 0,
];

$this->assertSame($expectedEmpty, ContactEndpointMock::list(Contact::FOLLOWER, 42));

$expectedFriendContactUser = [
'id' => 45,
'id_str' => '45',
'name' => 'Friend contact',
'screen_name' => 'friendcontact',
'location' => 'DFRN',
'derived' => [],
'url' => 'http://localhost/profile/friendcontact',
'entities' => [
'url' => [
'urls' => [],
],
'description' => [
'urls' => [],
],
],
'description' => '',
'protected' => false,
'verified' => false,
'followers_count' => 0,
'friends_count' => 0,
'listed_count' => 0,
'favourites_count' => 0,
'statuses_count' => 0,
'created_at' => 'Fri Feb 02 00:00:00 +0000 0000',
'profile_banner_url' => '',
'profile_image_url_https' => '',
'default_profile' => false,
'default_profile_image' => false,
'profile_image_url' => '',
'profile_image_url_profile_size' => '',
'profile_image_url_large' => '',
'utc_offset' => 0,
'time_zone' => 'UTC',
'geo_enabled' => false,
'lang' => NULL,
'contributors_enabled' => false,
'is_translator' => false,
'is_translation_enabled' => false,
'following' => false,
'follow_request_sent' => false,
'statusnet_blocking' => false,
'notifications' => false,
'uid' => 42,
'cid' => 44,
'pid' => 45,
'self' => 0,
'network' => 'dfrn',
'statusnet_profile_url' => 'http://localhost/profile/friendcontact',
];

$result = ContactEndpointMock::list(Contact::SHARING, 42);

$this->assertArrayHasKey('users', $result);
$this->assertContainsOnlyInstancesOf(User::class, $result['users']);
$this->assertSame($expectedFriendContactUser, $result['users'][0]->toArray());

$result = ContactEndpointMock::list([Contact::SHARING, Contact::FRIEND], 42);

$this->assertArrayHasKey('users', $result);
$this->assertContainsOnlyInstancesOf(User::class, $result['users']);
$this->assertSame($expectedFriendContactUser, $result['users'][0]->toArray());
}
}

Loading…
Cancel
Save