├── .github ├── FUNDING.yml └── workflows │ └── php.yml ├── .gitignore ├── .php_cs ├── .styleci.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── UPGRADE.md ├── codecov.yml ├── composer.json ├── config └── twitter.php ├── phpunit.xml.dist ├── src ├── ApiV1 │ ├── Contract │ │ └── Twitter.php │ ├── Service │ │ └── Twitter.php │ └── Traits │ │ ├── AccountActivityTrait.php │ │ ├── AccountTrait.php │ │ ├── AuthTrait.php │ │ ├── BlockTrait.php │ │ ├── DirectMessageTrait.php │ │ ├── FavoriteTrait.php │ │ ├── FormattingHelpers.php │ │ ├── FriendshipTrait.php │ │ ├── GeoTrait.php │ │ ├── HelpTrait.php │ │ ├── ListTrait.php │ │ ├── MediaTrait.php │ │ ├── SearchTrait.php │ │ ├── StatusTrait.php │ │ ├── TrendTrait.php │ │ └── UserTrait.php ├── Concern │ ├── ApiV2Behavior.php │ ├── FilteredStream.php │ ├── Follows.php │ ├── HideReplies.php │ ├── HotSwapper.php │ ├── SampledStream.php │ ├── SearchTweets.php │ ├── Timelines.php │ ├── TweetCounts.php │ ├── TweetLookup.php │ └── UserLookup.php ├── Configuration.php ├── Contract │ ├── Configuration.php │ ├── Http │ │ ├── AsyncClient.php │ │ ├── Client.php │ │ ├── ClientFactory.php │ │ └── SyncClient.php │ ├── Querier.php │ ├── ServiceProvider.php │ └── Twitter.php ├── Exception │ ├── AuthException.php │ ├── ClientException.php │ ├── InvalidConfigException.php │ ├── Request │ │ ├── BadRequestException.php │ │ ├── ForbiddenRequestException.php │ │ ├── NotFoundException.php │ │ ├── RateLimitedException.php │ │ ├── RequestFailureException.php │ │ ├── ServerErrorException.php │ │ └── UnauthorizedRequestException.php │ └── TwitterException.php ├── Facade │ └── Twitter.php ├── Http │ ├── Client.php │ ├── Client │ │ ├── AsyncClient.php │ │ └── SyncClient.php │ ├── Factory │ │ ├── BrowserCreator.php │ │ ├── ClientCreator.php │ │ └── GuzzleClientBuilder.php │ └── OAuth2Provider.php ├── Service │ ├── Accessor.php │ └── Querier.php ├── ServiceProvider │ ├── LaravelServiceProvider.php │ └── PhpDiServiceProvider.php └── Twitter.php └── tests ├── Integration ├── Laravel │ ├── TestCase.php │ └── TwitterTest.php └── PhpDi │ └── TwitterTest.php └── Unit ├── AccessorTestCase.php ├── ApiV1 ├── Service │ └── TwitterTest.php └── Traits │ ├── ConcernTestCase.php │ └── FormattingHelpersTest.php ├── Concern ├── ConcernTestCase.php ├── FilteredStreamTest.php ├── FollowsTest.php ├── HideRepliesTest.php ├── HotSwapperTest.php ├── SampledStreamTest.php ├── SearchTweetsTest.php ├── TimelinesTest.php ├── TweetCountsTest.php ├── TweetLookupTest.php └── UserLookupTest.php ├── Http └── Client │ ├── AsyncClientTest.php │ └── SyncClientTest.php └── Service ├── AccessorTest.php └── QuerierTest.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [atymic, reliq] 4 | -------------------------------------------------------------------------------- /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: PHP 2 | 3 | on: [ push ] 4 | 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | fail-fast: true 11 | matrix: 12 | php: [ 8.1, 8.2, 8.3, 8.4 ] 13 | laravel: [ 10, 11, 12 ] 14 | exclude: 15 | - php: 8.1 16 | laravel: 11 17 | - php: 8.1 18 | laravel: 12 19 | - php: 8.4 20 | laravel: 10 21 | 22 | name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v4 26 | 27 | - name: Setup PHP 28 | uses: shivammathur/setup-php@v2 29 | with: 30 | php-version: ${{ matrix.php }} 31 | extensions: mbstring, pdo, sqlite, pdo_sqlite 32 | ini-values: error_reporting=E_ALL 33 | tools: composer:v2 34 | coverage: none 35 | 36 | - name: Install dependencies 37 | run: | 38 | composer update --prefer-dist --no-interaction --no-progress --with="illuminate/contracts=^${{ matrix.laravel }}" 39 | 40 | - name: Execute tests 41 | run: vendor/bin/phpunit ${{ matrix.laravel >= 10 && '--display-deprecations' || '' }} 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | !.gitkeep 2 | 3 | /vendor 4 | composer.phar 5 | composer.lock 6 | .DS_Store 7 | *.cache 8 | .idea 9 | .phpunit.result.cache 10 | phpunit.xml 11 | temp/ 12 | -------------------------------------------------------------------------------- /.php_cs: -------------------------------------------------------------------------------- 1 | exclude([ 5 | '.github', 6 | ]) 7 | ->in(__DIR__); 8 | 9 | $config = PhpCsFixer\Config::create() 10 | ->setRiskyAllowed(true) 11 | ->setRules([ 12 | '@PHP56Migration' => true, 13 | '@PHPUnit60Migration:risky' => true, 14 | '@Symfony' => true, 15 | '@Symfony:risky' => false, 16 | 'align_multiline_comment' => true, 17 | 'array_indentation' => true, 18 | 'array_syntax' => ['syntax' => 'short'], 19 | 'blank_line_before_statement' => true, 20 | 'binary_operator_spaces' => ['default' => 'single_space'], 21 | 'combine_consecutive_issets' => true, 22 | 'combine_consecutive_unsets' => true, 23 | 'comment_to_phpdoc' => true, 24 | 'compact_nullable_typehint' => true, 25 | 'cast_spaces' => ['space' => 'none'], 26 | 'concat_space' => ['spacing' => 'one'], 27 | 'escape_implicit_backslashes' => true, 28 | 'explicit_indirect_variable' => true, 29 | 'explicit_string_variable' => true, 30 | 'final_internal_class' => true, 31 | 'fully_qualified_strict_types' => true, 32 | 'function_to_constant' => ['functions' => ['get_class', 'get_called_class', 'php_sapi_name', 'phpversion', 'pi']], 33 | // 'header_comment' => ['header' => $header], 34 | 'heredoc_to_nowdoc' => true, 35 | 'list_syntax' => ['syntax' => 'long'], 36 | 'logical_operators' => true, 37 | 'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline'], 38 | 'method_chaining_indentation' => true, 39 | 'multiline_comment_opening_closing' => true, 40 | 'no_alternative_syntax' => true, 41 | 'no_binary_string' => true, 42 | 'no_extra_blank_lines' => ['tokens' => ['break', 'continue', 'extra', 'return', 'throw', 'use', 'parenthesis_brace_block', 'square_brace_block', 'curly_brace_block']], 43 | 'no_null_property_initialization' => true, 44 | 'no_short_echo_tag' => true, 45 | 'no_superfluous_elseif' => true, 46 | 'no_unneeded_curly_braces' => true, 47 | 'no_unneeded_final_method' => true, 48 | 'no_unreachable_default_argument_value' => true, 49 | 'no_unset_on_property' => true, 50 | 'no_useless_else' => true, 51 | 'no_useless_return' => true, 52 | 'ordered_class_elements' => true, 53 | 'ordered_imports' => false, 54 | 'php_unit_internal_class' => true, 55 | 'php_unit_method_casing' => true, 56 | 'php_unit_ordered_covers' => true, 57 | 'php_unit_set_up_tear_down_visibility' => true, 58 | 'php_unit_strict' => true, 59 | 'php_unit_test_annotation' => true, 60 | 'php_unit_test_case_static_method_calls' => ['call_type' => 'this'], 61 | 'php_unit_test_class_requires_covers' => true, 62 | 'phpdoc_add_missing_param_annotation' => true, 63 | 'phpdoc_order' => false, 64 | 'phpdoc_trim_consecutive_blank_line_separation' => true, 65 | 'phpdoc_separation' => false, 66 | 'phpdoc_types_order' => true, 67 | 'return_assignment' => true, 68 | 'semicolon_after_instruction' => true, 69 | 'single_line_comment_style' => true, 70 | 'strict_comparison' => true, 71 | 'strict_param' => true, 72 | 'string_line_ending' => true, 73 | 'yoda_style' => false, 74 | ]) 75 | ->setFinder($finder); 76 | 77 | // special handling of fabbot.io service if it's using too old PHP CS Fixer version 78 | if (false !== getenv('FABBOT_IO')) { 79 | try { 80 | PhpCsFixer\FixerFactory::create() 81 | ->registerBuiltInFixers() 82 | ->registerCustomFixers($config->getCustomFixers()) 83 | ->useRuleSet(new PhpCsFixer\RuleSet($config->getRules())); 84 | } catch (PhpCsFixer\ConfigurationException\InvalidConfigurationException $e) { 85 | $config->setRules([]); 86 | } catch (UnexpectedValueException $e) { 87 | $config->setRules([]); 88 | } catch (InvalidArgumentException $e) { 89 | $config->setRules([]); 90 | } 91 | } 92 | 93 | return $config; 94 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: laravel 2 | 3 | disabled: 4 | - concat_without_spaces 5 | - not_operator_with_successor_space 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [3.1.14] 9 | 10 | ### Fixed 11 | 12 | Support `psr/log` v2/v3 [#379](https://github.com/atymic/twitter/pull/379) 13 | 14 | ## [3.1.13] 15 | 16 | ### Fixed 17 | 18 | Guzzle Oauth Subscriber PSR7 2.x compat [#373](https://github.com/atymic/twitter/pull/373) 19 | 20 | ## [3.1.12] 21 | 22 | ### Fixed 23 | 24 | Re-add account activity trait [#372](https://github.com/atymic/twitter/pull/372) 25 | 26 | ## [3.1.11] 27 | 28 | ### Fixed 29 | 30 | Require guzzlehttp/psr7 v1.x [#370](https://github.com/atymic/twitter/pull/370) 31 | 32 | ## [3.1.10] 33 | 34 | ### Added 35 | 36 | Expose new tweet count endpoints [#366](https://github.com/atymic/twitter/pull/366) 37 | 38 | ## [3.1.9] 39 | 40 | ### Added 41 | 42 | Add ability to get last response (for checking headers such as rate limits) [#359](https://github.com/atymic/twitter/pull/359) 43 | 44 | ## [3.1.8] 45 | 46 | ### Fixed 47 | 48 | Prior to this, hot-swapping methods (`forApiV1()` and `forApiV2()`) did not actually swap service implementations. #357 49 | 50 | ## [3.1.6] 51 | 52 | ### Fixed 53 | 54 | Unable to switch API version on service (#356) 55 | 56 | ## [3.1.5] 57 | 58 | ### Fixed 59 | 60 | Ensure `Twitter` service acts as singleton [#352](https://github.com/atymic/twitter/pull/352) 61 | 62 | ## [3.1.4] 63 | 64 | ### Fixed 65 | 66 | Fixed incorrect import in `AuthTrait` [#347](https://github.com/atymic/twitter/pull/347) 67 | 68 | 69 | ## [3.1.3] 70 | 71 | ### Fixed 72 | 73 | Fixed `getOembed` querying the wrong hostname/endpoint [#345](https://github.com/atymic/twitter/pull/345) 74 | 75 | 76 | ## [3.1.2] 77 | 78 | ### Fixed 79 | 80 | Fixed `directQuery` param order [#343](https://github.com/atymic/twitter/pull/343) 81 | 82 | 83 | ## [3.1.1] 84 | 85 | ### Fixed 86 | 87 | Fixed `getOembed` url containing invalid full stop [8d9b15](https://github.com/atymic/twitter/commit/8d9b15dcdb88e21fc66c8d7bc582e4839d814dc0) 88 | 89 | ## [3.1.0] 90 | 91 | ### Added 92 | 93 | - Twitter API v2 Support [#337](https://github.com/atymic/twitter/pull/337) 94 | 95 | ## [3.0.0] 96 | 97 | See [UPGRADE.md](./UPGRADE.md) for the upgrade guide. 98 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) <2019> 4 | Copyright (c) <2019> 5 | Copyright (c) <2013> 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of 8 | this software and associated documentation files (the "Software"), to deal in 9 | the Software without restriction, including without limitation the rights to 10 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 11 | the Software, and to permit persons to whom the Software is furnished to do so, 12 | subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 19 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 20 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 21 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 22 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | # 3.x Upgrade Guide 2 | 3 | ### Namespace Change 4 | 5 | We have moved the package namespace from `Thujohn\Twitter` to `Atymic\Twitter`. 6 | 7 | You need to change all references of the old namespace to the new one. 8 | 9 | ### Config file changes 10 | 11 | The keys in the config file have changed. If you did not publish the config file and make changes, you do not need to do this step since the environment variable names have not changed. 12 | 13 | Run `php artisan vendor:publish --provider="Atymic\Twitter\ServiceProvider\LaravelServiceProvider"` and compare the old `ttwitter` config file with the new one, moving your changes across. Then delete the old config file. 14 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | github_checks: 2 | annotations: false 3 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "atymic/twitter", 3 | "description": "Twitter API for PHP & Laravel", 4 | "keywords": ["twitter", "laravel"], 5 | "license": "MIT", 6 | "type": "library", 7 | "authors": [ 8 | { 9 | "name": "atymic", 10 | "email": "atymicq@gmail.com", 11 | "homepage": "https://atymic.dev" 12 | }, 13 | { 14 | "name": "reliq", 15 | "email": "reliq@reliqarts.com", 16 | "homepage": "https://iamreliq.com" 17 | } 18 | ], 19 | "require": { 20 | "php": "^8.1", 21 | "ext-json": "*", 22 | "illuminate/auth": "^10.0|^11.0|^12.0", 23 | "guzzlehttp/guzzle": "^6.4 || ^7.0", 24 | "psr/log": "^1.1 || ^2.0 || ^3.0", 25 | "nesbot/carbon": "^2.26 || ^3.0", 26 | "guzzlehttp/oauth-subscriber": "^0.6 || ^0.8", 27 | "php-di/php-di": "^7.0.2", 28 | "kamermans/guzzle-oauth2-subscriber": "^1.0", 29 | "phpoption/phpoption": "^1.7", 30 | "vlucas/phpdotenv": "*", 31 | "react/http": "^1.2", 32 | "league/oauth2-client": "^2.6" 33 | }, 34 | "require-dev": { 35 | "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", 36 | "orchestra/testbench": "^8.14|^9.0|^10.0", 37 | "phpspec/prophecy-phpunit": "^2.0" 38 | }, 39 | "autoload": { 40 | "psr-4": { 41 | "Atymic\\Twitter\\": "src/" 42 | } 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "Atymic\\Twitter\\Tests\\": "tests" 47 | }, 48 | "files": [ 49 | "vendor/phpunit/phpunit/src/Framework/Assert/Functions.php" 50 | ] 51 | }, 52 | "scripts": { 53 | "test": "phpunit", 54 | "test:ci": "phpunit --verbose --coverage-clover=coverage.xml" 55 | }, 56 | "extra": { 57 | "laravel": { 58 | "providers": [ 59 | "Atymic\\Twitter\\ServiceProvider\\LaravelServiceProvider" 60 | ], 61 | "aliases": { 62 | "Twitter": "Atymic\\Twitter\\Facade\\Twitter" 63 | } 64 | } 65 | }, 66 | "minimum-stability": "dev" 67 | } 68 | -------------------------------------------------------------------------------- /config/twitter.php: -------------------------------------------------------------------------------- 1 | env('APP_DEBUG', false), 7 | 8 | 'api_url' => 'api.twitter.com', 9 | 'upload_url' => 'upload.twitter.com', 10 | 'api_version' => env('TWITTER_API_VERSION', '1.1'), 11 | 12 | 'consumer_key' => env('TWITTER_CONSUMER_KEY'), 13 | 'consumer_secret' => env('TWITTER_CONSUMER_SECRET'), 14 | 'access_token' => env('TWITTER_ACCESS_TOKEN'), 15 | 'access_token_secret' => env('TWITTER_ACCESS_TOKEN_SECRET'), 16 | 17 | 'authenticate_url' => 'https://api.twitter.com/oauth/authenticate', 18 | 'access_token_url' => 'https://api.twitter.com/oauth/access_token', 19 | 'request_token_url' => 'https://api.twitter.com/oauth/request_token', 20 | ]; 21 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | tests 14 | 15 | 16 | 17 | 18 | src 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/ApiV1/Contract/Twitter.php: -------------------------------------------------------------------------------- 1 | setQuerier($querier); 59 | } 60 | 61 | public function getQuerier(): Querier 62 | { 63 | return $this->querier; 64 | } 65 | 66 | /** 67 | * @return mixed 68 | * 69 | * @throws TwitterClientException 70 | */ 71 | public function query( 72 | string $endpoint, 73 | string $requestMethod = self::REQUEST_METHOD_GET, 74 | array $parameters = [], 75 | bool $multipart = false, 76 | string $extension = self::DEFAULT_EXTENSION 77 | ) { 78 | return $this->querier->query($endpoint, $requestMethod, $parameters, $multipart, $extension); 79 | } 80 | 81 | /** 82 | * @return mixed 83 | * 84 | * @throws TwitterClientException 85 | */ 86 | public function directQuery( 87 | string $url, 88 | string $requestMethod = self::REQUEST_METHOD_GET, 89 | array $parameters = [] 90 | ) { 91 | return $this->querier->directQuery($url, $requestMethod, $parameters); 92 | } 93 | 94 | /** 95 | * @param array $parameters 96 | * @param bool $multipart 97 | * @param string $extension 98 | * @return mixed|string 99 | * 100 | * @throws TwitterClientException 101 | */ 102 | public function get(string $endpoint, $parameters = [], $multipart = false, $extension = self::DEFAULT_EXTENSION) 103 | { 104 | return $this->query($endpoint, self::REQUEST_METHOD_GET, $parameters, $multipart, $extension); 105 | } 106 | 107 | /** 108 | * @return mixed 109 | * 110 | * @throws TwitterClientException 111 | */ 112 | public function post(string $endpoint, array $parameters = [], bool $multipart = false) 113 | { 114 | return $this->query($endpoint, self::REQUEST_METHOD_POST, $parameters, $multipart); 115 | } 116 | 117 | /** 118 | * @return mixed 119 | * 120 | * @throws TwitterClientException 121 | */ 122 | public function delete(string $endpoint, array $parameters = []) 123 | { 124 | return $this->query($endpoint, self::REQUEST_METHOD_DELETE, $parameters); 125 | } 126 | 127 | private function setQuerier(Querier $querier): self 128 | { 129 | $config = $querier->getConfiguration(); 130 | $this->config = $config; 131 | $this->querier = $querier; 132 | $this->debug = $config->isDebugMode(); 133 | 134 | return $this; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/ApiV1/Traits/AccountActivityTrait.php: -------------------------------------------------------------------------------- 1 | getQuerier() 24 | ->getConfiguration() 25 | ->getConsumerSecret(); 26 | $hash = hash_hmac('sha256', $crcToken, $secret, true); 27 | 28 | return 'sha256=' . base64_encode($hash); 29 | } 30 | 31 | /** 32 | * Registers a webhook $url for all event types in the given environment. 33 | * 34 | * @param mixed $env 35 | * @param mixed $url 36 | * @return object 37 | * 38 | * @throws TwitterException 39 | */ 40 | public function setWebhook($env, $url) 41 | { 42 | return $this->post("account_activity/all/{$env}/webhooks", ['url' => $url]); 43 | } 44 | 45 | /** 46 | * Returns webhook URLs for the given environment (or all environments if none provided), and their statuses for the authenticating app. 47 | * 48 | * @param mixed $env 49 | * @return object 50 | * 51 | * @throws TwitterException 52 | */ 53 | public function getWebhooks($env = null) 54 | { 55 | return $this->get('account_activity/all/' . ($env ? $env . '/' : '') . 'webhooks'); 56 | } 57 | 58 | /** 59 | * Triggers the challenge response check (CRC) for the given environments webhook for all activities. 60 | * If the check is successful, returns 204 and re-enables the webhook by setting its status to valid. 61 | * 62 | * @param mixed $env 63 | * @param mixed $webhookId 64 | * @return bool 65 | * 66 | * @throws TwitterException 67 | */ 68 | public function updateWebhooks($env, $webhookId): bool 69 | { 70 | $this->query("account_activity/all/{$env}/webhooks/{$webhookId}", 'PUT'); 71 | 72 | $response = $this->getQuerier() 73 | ->getSyncClient() 74 | ->getLastResponse(); 75 | 76 | return $response !== null && $response->getStatusCode() === Response::HTTP_NO_CONTENT; 77 | } 78 | 79 | /** 80 | * Removes the webhook from the provided application's all activities configuration. 81 | * The webhook ID can be accessed by making a call to GET /1.1/account_activity/all/webhooks (getWebhooks). 82 | * 83 | * @param mixed $env 84 | * @param mixed $webhookId 85 | * @return bool 86 | * 87 | * @throws TwitterException 88 | */ 89 | public function destroyWebhook($env, $webhookId): bool 90 | { 91 | $this->delete("account_activity/all/{$env}/webhooks/{$webhookId}"); 92 | 93 | $response = $this->getQuerier() 94 | ->getSyncClient() 95 | ->getLastResponse(); 96 | 97 | return $response !== null && $response->getStatusCode() === Response::HTTP_NO_CONTENT; 98 | } 99 | 100 | /** 101 | * Subscribes the provided application to all events for the provided environment for all message types. 102 | * Returns HTTP 204 on success. 103 | * After activation, all events for the requesting user will be sent to the application’s webhook via POST request. 104 | * 105 | * @param mixed $env 106 | * @return bool 107 | * 108 | * @throws TwitterException 109 | */ 110 | public function setSubscriptions($env): bool 111 | { 112 | $this->post("account_activity/all/{$env}/subscriptions"); 113 | 114 | $response = $this->getQuerier() 115 | ->getSyncClient() 116 | ->getLastResponse(); 117 | 118 | return $response !== null && $response->getStatusCode() === Response::HTTP_NO_CONTENT; 119 | } 120 | 121 | /** 122 | * Provides a way to determine if a webhook configuration is subscribed to the provided user’s events. 123 | * If the provided user context has an active subscription with provided application, returns 204 OK. 124 | * If the response code is not 204, then the user does not have an active subscription. 125 | * See HTTP Response code and error messages for details: 126 | * https://developer.twitter.com/en/docs/accounts-and-users/subscribe-account-activity/api-reference/aaa-premium#get-account-activity-all-env-name-subscriptions. 127 | * 128 | * @param mixed $env 129 | * @return bool 130 | * 131 | * @throws TwitterException 132 | */ 133 | public function getSubscriptions($env): bool 134 | { 135 | $this->get("account_activity/all/{$env}/subscriptions"); 136 | 137 | $response = $this->getQuerier() 138 | ->getSyncClient() 139 | ->getLastResponse(); 140 | 141 | return $response !== null && $response->getStatusCode() === Response::HTTP_NO_CONTENT; 142 | } 143 | 144 | /** 145 | * Returns the count of subscriptions that are currently active on your account for all activities. 146 | * 147 | * @return mixed 148 | * 149 | * @throws TwitterException 150 | */ 151 | public function getSubscriptionsCount() 152 | { 153 | return $this->get('account_activity/all/subscriptions/count', [], false, 'json'); 154 | } 155 | 156 | /** 157 | * Returns a list of the current All Activity type subscriptions. 158 | * 159 | * @param mixed $env 160 | * @return mixed 161 | * 162 | * @throws TwitterException 163 | */ 164 | public function getSubscriptionsList($env) 165 | { 166 | return $this->get("account_activity/all/{$env}/subscriptions/list", [], false, 'json'); 167 | } 168 | 169 | /** 170 | * Deactivates subscription for the specified user id from the environment. 171 | * After deactivation, all events for the requesting user will no longer be sent to the webhook URL. 172 | * 173 | * @param mixed $env 174 | * @param mixed $userId 175 | * @return bool 176 | * 177 | * @throws TwitterException 178 | */ 179 | public function destroyUserSubscriptions($env, $userId): bool 180 | { 181 | $this->delete("account_activity/all/{$env}/subscriptions/{$userId}", []); 182 | 183 | $response = $this->getQuerier() 184 | ->getSyncClient() 185 | ->getLastResponse(); 186 | 187 | return $response !== null && $response->getStatusCode() === Response::HTTP_NO_CONTENT; 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/ApiV1/Traits/AccountTrait.php: -------------------------------------------------------------------------------- 1 | get('account/settings', $parameters); 17 | } 18 | 19 | /** 20 | * Returns an HTTP 200 OK response code and a representation of the requesting user if authentication was 21 | * successful; returns a 401 status code and an error message if not. 22 | * Use this method to test if supplied user credentials are valid. 23 | * 24 | * Parameters : 25 | * - include_entities (0|1) 26 | * - skip_status (0|1) 27 | * 28 | * @param mixed $parameters 29 | */ 30 | public function getCredentials($parameters = []) 31 | { 32 | return $this->get('account/verify_credentials', $parameters); 33 | } 34 | 35 | /** 36 | * Updates the authenticating user’s settings. 37 | * 38 | * Parameters : 39 | * - trend_location_woeid 40 | * - sleep_time_enabled (0|1) 41 | * - start_sleep_time 42 | * - end_sleep_time 43 | * - time_zone 44 | * - lang 45 | * 46 | * @param mixed $parameters 47 | */ 48 | public function postSettings($parameters = []) 49 | { 50 | if (empty($parameters)) { 51 | throw new BadMethodCallException('Parameter missing'); 52 | } 53 | 54 | return $this->post('account/settings', $parameters); 55 | } 56 | 57 | /** 58 | * Sets which device Twitter delivers updates to for the authenticating user. 59 | * Sending none as the device parameter will disable SMS updates. 60 | * 61 | * Parameters : 62 | * - device (sms|none) 63 | * - include_entities (0|1) 64 | * 65 | * @param mixed $parameters 66 | */ 67 | public function postSettingsDevice($parameters = []) 68 | { 69 | if (!array_key_exists('device', $parameters)) { 70 | throw new BadMethodCallException('Parameter required missing : device'); 71 | } 72 | 73 | return $this->post('account/update_delivery_device', $parameters); 74 | } 75 | 76 | /** 77 | * Sets some values that users are able to set under the “Account” tab of their settings page. Only the parameters specified will be updated. 78 | * 79 | * Parameters : 80 | * - name 81 | * - url 82 | * - location 83 | * - description (0-160) 84 | * - include_entities (0|1) 85 | * - skip_status (0|1) 86 | * 87 | * @param mixed $parameters 88 | */ 89 | public function postProfile($parameters = []) 90 | { 91 | if (empty($parameters)) { 92 | throw new BadMethodCallException('Parameter missing'); 93 | } 94 | 95 | return $this->post('account/update_profile', $parameters); 96 | } 97 | 98 | /** 99 | * Updates the authenticating user’s profile background image. This method can also be used to enable or disable the profile background image. 100 | * 101 | * Parameters : 102 | * - image 103 | * - tile 104 | * - include_entities (0|1) 105 | * - skip_status (0|1) 106 | * - use (0|1) 107 | * 108 | * @param mixed $parameters 109 | */ 110 | public function postBackground($parameters = []) 111 | { 112 | if (!array_key_exists('image', $parameters) && !array_key_exists('tile', $parameters) && !array_key_exists('use', $parameters)) { 113 | throw new BadMethodCallException('Parameter required missing : image, tile or use'); 114 | } 115 | 116 | return $this->post('account/update_profile_background_image', $parameters, true); 117 | } 118 | 119 | /** 120 | * Updates the authenticating user’s profile image. Note that this method expects raw multipart data, not a URL to an image. 121 | * 122 | * Parameters : 123 | * - image 124 | * - include_entities (0|1) 125 | * - skip_status (0|1) 126 | * 127 | * @param mixed $parameters 128 | */ 129 | public function postProfileImage($parameters = []) 130 | { 131 | if (!array_key_exists('image', $parameters)) { 132 | throw new BadMethodCallException('Parameter required missing : image'); 133 | } 134 | 135 | return $this->post('account/update_profile_image', $parameters, false); 136 | } 137 | 138 | /** 139 | * Removes the uploaded profile banner for the authenticating user. Returns HTTP 200 upon success. 140 | * 141 | * @param mixed $parameters 142 | */ 143 | public function destroyUserBanner($parameters = []) 144 | { 145 | return $this->post('account/remove_profile_banner', $parameters); 146 | } 147 | 148 | /** 149 | * Uploads a profile banner on behalf of the authenticating user. For best results, upload an profile_banner_url node in their Users objects. More information about sizing variations can be found in User Profile Images and Banners and GET users / profile_banner. 150 | * 151 | * Parameters : 152 | * - banner 153 | * - width 154 | * - height 155 | * - offset_left 156 | * - offset_top 157 | * 158 | * @param mixed $parameters 159 | */ 160 | public function postUserBanner($parameters = []) 161 | { 162 | if (!array_key_exists('banner', $parameters)) { 163 | throw new BadMethodCallException('Parameter required missing : banner'); 164 | } 165 | 166 | return $this->post('account/update_profile_banner', $parameters); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/ApiV1/Traits/AuthTrait.php: -------------------------------------------------------------------------------- 1 | config->withoutOauthCredentials(); 20 | $tokenEndpoint = $config->getRequestTokenUrl() ?? ''; 21 | $responseBody = $this->directQuery( 22 | $tokenEndpoint, 23 | self::REQUEST_METHOD_GET, 24 | [ 25 | Twitter::KEY_OAUTH_CALLBACK => $callbackUrl, 26 | Twitter::KEY_RESPONSE_FORMAT => self::RESPONSE_FORMAT_JSON, 27 | ] 28 | ); 29 | 30 | parse_str($responseBody, $token); 31 | if (isset($token[Twitter::KEY_OAUTH_TOKEN], $token[Twitter::KEY_OAUTH_TOKEN_SECRET])) { 32 | return $token; 33 | } 34 | 35 | throw new AuthException(sprintf('Failed to fetch request token. Response content: %s', $responseBody)); 36 | } 37 | 38 | public function getAuthenticateUrl(string $oauthToken): string 39 | { 40 | return sprintf('%s?%s=%s', $this->config->getAuthenticateUrl(), Twitter::KEY_OAUTH_TOKEN, $oauthToken); 41 | } 42 | 43 | /** 44 | * Get an access token for a logged in user. 45 | * 46 | * @throws AuthException 47 | */ 48 | public function getAccessToken(string $oauthVerifier): array 49 | { 50 | $accessTokenEndpoint = $this->config->getAccessTokenUrl(); 51 | $responseBody = $this->directQuery( 52 | $accessTokenEndpoint, 53 | self::REQUEST_METHOD_GET, 54 | [ 55 | Twitter::KEY_OAUTH_VERIFIER => $oauthVerifier, 56 | Twitter::KEY_RESPONSE_FORMAT => self::RESPONSE_FORMAT_JSON, 57 | ] 58 | ); 59 | 60 | parse_str($responseBody, $token); 61 | if (isset($token[Twitter::KEY_OAUTH_TOKEN], $token[Twitter::KEY_OAUTH_TOKEN_SECRET])) { 62 | return $token; 63 | } 64 | 65 | throw new AuthException(sprintf('Failed to fetch access token. Response content: %s', $responseBody)); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/ApiV1/Traits/BlockTrait.php: -------------------------------------------------------------------------------- 1 | get('blocks/list', $parameters); 22 | } 23 | 24 | /** 25 | * Returns an array of numeric user ids the authenticating user is blocking. 26 | * 27 | * Parameters : 28 | * - stringify_ids (0|1) 29 | * - cursor 30 | * 31 | * @param mixed $parameters 32 | */ 33 | public function getBlocksIds($parameters = []) 34 | { 35 | return $this->get('blocks/ids', $parameters); 36 | } 37 | 38 | /** 39 | * Blocks the specified user from following the authenticating user. In addition the blocked user will not show in the authenticating users mentions or timeline (unless retweeted by another user). If a follow or friend relationship exists it is destroyed. 40 | * 41 | * Parameters : 42 | * - screen_name 43 | * - user_id 44 | * - include_entities (0|1) 45 | * - skip_status (0|1) 46 | * 47 | * @param mixed $parameters 48 | */ 49 | public function postBlock($parameters = []) 50 | { 51 | if (!array_key_exists('screen_name', $parameters) && !array_key_exists('user_id', $parameters)) { 52 | throw new BadMethodCallException('Parameter required missing : screen_name or user_id'); 53 | } 54 | 55 | return $this->post('blocks/create', $parameters); 56 | } 57 | 58 | /** 59 | * Un-blocks the user specified in the ID parameter for the authenticating user. Returns the un-blocked user in the requested format when successful. If relationships existed before the block was instated, they will not be restored. 60 | * 61 | * Parameters : 62 | * - screen_name 63 | * - user_id 64 | * - include_entities (0|1) 65 | * - skip_status (0|1) 66 | * 67 | * @param mixed $parameters 68 | */ 69 | public function destroyBlock($parameters = []) 70 | { 71 | if (!array_key_exists('screen_name', $parameters) && !array_key_exists('user_id', $parameters)) { 72 | throw new BadMethodCallException('Parameter required missing : screen_name or user_id'); 73 | } 74 | 75 | return $this->post('blocks/destroy', $parameters); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/ApiV1/Traits/DirectMessageTrait.php: -------------------------------------------------------------------------------- 1 | get('direct_messages/events/show', $parameters); 25 | } 26 | 27 | /** 28 | * Returns all Direct Message events (both sent and received) within the last 30 days. Sorted in reverse-chronological order. 29 | * 30 | * Parameters : 31 | * - count (1-50) 32 | * - cursor 33 | * 34 | * @param mixed $parameters 35 | */ 36 | public function getDms($parameters = []) 37 | { 38 | return $this->get('direct_messages/events/list', $parameters); 39 | } 40 | 41 | /** 42 | * Destroys the direct message specified in the required ID parameter. The authenticating user must be the recipient of the specified direct message. 43 | * 44 | * Parameters : 45 | * - id 46 | * 47 | * @param mixed $parameters 48 | */ 49 | public function destroyDm($parameters = []) 50 | { 51 | if (!array_key_exists('id', $parameters)) { 52 | throw new BadMethodCallException('Parameter required missing : id'); 53 | } 54 | 55 | return $this->delete('direct_messages/events/destroy', $parameters); 56 | } 57 | 58 | /** 59 | * Publishes a new message_create event resulting in a Direct Message sent to a specified user from the 60 | * authenticating user. Returns an event if successful. Supports publishing Direct Messages with optional Quick 61 | * Reply and media attachment. 62 | * 63 | * Parameters : 64 | * - type 65 | * - message_create 66 | * 67 | * @see https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/new-event 68 | * 69 | * @param mixed $parameters 70 | * 71 | * @throws BadMethodCallException 72 | */ 73 | public function postDm($parameters = []) 74 | { 75 | $apiReference = 'https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/new-event'; 76 | 77 | if (!array_key_exists('event', $parameters)) { 78 | throw new BadMethodCallException(sprintf('Missing required parameter: `event`. See %s', $apiReference)); 79 | } 80 | 81 | $parameters[Twitter::KEY_REQUEST_FORMAT] = Twitter::REQUEST_FORMAT_JSON; 82 | 83 | return $this->post('direct_messages/events/new', $parameters); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/ApiV1/Traits/FavoriteTrait.php: -------------------------------------------------------------------------------- 1 | get('favorites/list', $parameters); 25 | } 26 | 27 | /** 28 | * Un-favorites the status specified in the ID parameter as the authenticating user. Returns the un-favorited status in the requested format when successful. 29 | * 30 | * Parameters : 31 | * - id 32 | * - include_entities (0|1) 33 | * 34 | * @param mixed $parameters 35 | */ 36 | public function destroyFavorite($parameters = []) 37 | { 38 | if (!array_key_exists('id', $parameters)) { 39 | throw new BadMethodCallException('Parameter required missing : id'); 40 | } 41 | 42 | return $this->post('favorites/destroy', $parameters); 43 | } 44 | 45 | /** 46 | * Favorites the status specified in the ID parameter as the authenticating user. Returns the favorite status when successful. 47 | * 48 | * Parameters : 49 | * - id 50 | * - include_entities (0|1) 51 | * 52 | * @param mixed $parameters 53 | */ 54 | public function postFavorite($parameters = []) 55 | { 56 | if (!array_key_exists('id', $parameters)) { 57 | throw new BadMethodCallException('Parameter required missing : id'); 58 | } 59 | 60 | return $this->post('favorites/create', $parameters); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/ApiV1/Traits/FormattingHelpers.php: -------------------------------------------------------------------------------- 1 | ]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:\'".,<>?«»“”‘’]))'; 34 | $patterns['mailto'] = '([_a-z0-9-]+(\.[_a-z0-9-]+)*@[a-z0-9-]+(\.[a-z0-9-]+)*(\.[a-z]{2,3}))'; 35 | $patterns['user'] = '(?:\s+|^)@([A-Za-z0-9_]*)?'; 36 | $patterns['hashtag'] = '(?:(?<=\s)|^)#(\w*[\p{L}\-\d\p{Cyrillic}\d]+\w*)'; 37 | $patterns['long_url'] = '>(([[:alnum:]]+:\/\/)|www\.)?([^[:space:]]{12,22})([^[:space:]]*)([^[:space:]]{12,22})([[:alnum:]#?\/&=])<'; 38 | 39 | if ($type === 'text') { 40 | $text = preg_replace_callback( 41 | '#' . $patterns['url'] . '#i', 42 | static function ($matches) { 43 | $input = $matches[0]; 44 | $url = preg_match('!^https?://!i', $input) ? $input : "http://{$input}"; 45 | 46 | return '' . "{$input}"; 47 | }, 48 | sprintf(' %s', $tweet) 49 | ); 50 | } else { 51 | $text = $tweet['text']; 52 | $entities = $tweet['entities']; 53 | 54 | $search = []; 55 | $replace = []; 56 | 57 | if (array_key_exists('media', $entities)) { 58 | foreach ($entities['media'] as $media) { 59 | $search[] = $media['url']; 60 | $replace[] = '' . $media['display_url'] . ''; 61 | } 62 | } 63 | 64 | if (array_key_exists('urls', $entities)) { 65 | foreach ($entities['urls'] as $url) { 66 | $search[] = $url['url']; 67 | $replace[] = '' . $url['display_url'] . ''; 68 | } 69 | } 70 | 71 | $text = str_replace($search, $replace, $text); 72 | } 73 | 74 | if ($linkifyEmails) { 75 | $text = preg_replace('/' . $patterns['mailto'] . '/i', '\\1', $text); 76 | } 77 | 78 | if ($linkifyUsers) { 79 | $text = preg_replace( 80 | '/' . $patterns['user'] . '/i', 81 | ' @\\1', 82 | $text 83 | ); 84 | } 85 | 86 | if ($linkifyHashTags) { 87 | $text = preg_replace( 88 | '/' . $patterns['hashtag'] . '/ui', 89 | '#\\1', 90 | $text 91 | ); 92 | } 93 | 94 | // Long URL 95 | $text = preg_replace('/' . $patterns['long_url'] . '/', '>\\3...\\5\\6<', $text); 96 | 97 | // Remove multiple spaces 98 | $text = preg_replace('/\s+/', ' ', $text); 99 | 100 | return trim($text); 101 | } 102 | 103 | // todo figure out how this is used and refactor 104 | public function ago($timestamp): string 105 | { 106 | if (is_numeric($timestamp) && (int) $timestamp === $timestamp) { 107 | $carbon = Carbon::createFromTimeStamp($timestamp); 108 | } else { 109 | $dt = new \DateTime($timestamp); 110 | $carbon = Carbon::instance($dt); 111 | } 112 | 113 | return $carbon->diffForHumans(); 114 | } 115 | 116 | // todo redo these helpers 117 | 118 | /** 119 | * @param object|array|string $user 120 | * @return string 121 | */ 122 | public function linkUser($user): string 123 | { 124 | $screenName = is_string($user) ? $user : $this->objectToArray($user)['screen_name']; 125 | 126 | return 'https://twitter.com/' . $screenName; 127 | } 128 | 129 | /** 130 | * @param object|array $tweet 131 | * @return string 132 | */ 133 | public function linkTweet($tweet): string 134 | { 135 | $tweet = $this->objectToArray($tweet); 136 | 137 | return $this->linkUser($tweet['user']) . '/status/' . $tweet['id_str']; 138 | } 139 | 140 | /** 141 | * @param object|array $tweet 142 | * @return string 143 | */ 144 | public function linkRetweet($tweet): string 145 | { 146 | $tweet = $this->objectToArray($tweet); 147 | 148 | return 'https://twitter.com/intent/retweet?tweet_id=' . $tweet['id_str']; 149 | } 150 | 151 | /** 152 | * @param object|array $tweet 153 | * @return string 154 | */ 155 | public function linkAddTweetToFavorites($tweet): string 156 | { 157 | $tweet = $this->objectToArray($tweet); 158 | 159 | return 'https://twitter.com/intent/favorite?tweet_id=' . $tweet['id_str']; 160 | } 161 | 162 | /** 163 | * @param object|array $tweet 164 | * @return string 165 | */ 166 | public function linkReply($tweet): string 167 | { 168 | $tweet = $this->objectToArray($tweet); 169 | 170 | return 'https://twitter.com/intent/tweet?in_reply_to=' . $tweet['id_str']; 171 | } 172 | 173 | /** 174 | * @param $data 175 | * @return array|mixed 176 | */ 177 | protected function objectToArray($data) 178 | { 179 | if (is_array($data)) { 180 | return $data; 181 | } 182 | 183 | if (is_object($data)) { 184 | return json_decode(json_encode($data), true); 185 | } 186 | 187 | // Fallback for non objects 188 | return $data; 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/ApiV1/Traits/FriendshipTrait.php: -------------------------------------------------------------------------------- 1 | get('friendships/no_retweets/ids', $parameters); 20 | } 21 | 22 | /** 23 | * Returns a cursored collection of user IDs for every user following the specified user. 24 | * 25 | * Parameters : 26 | * - user_id 27 | * - screen_name 28 | * - cursor 29 | * - stringify_ids (0|1) 30 | * - count (1-5000) 31 | * 32 | * @param mixed $parameters 33 | */ 34 | public function getFriendsIds($parameters = []) 35 | { 36 | return $this->get('friends/ids', $parameters); 37 | } 38 | 39 | /** 40 | * Returns a cursored collection of user IDs for every user following the specified user. 41 | * 42 | * Parameters : 43 | * - user_id 44 | * - screen_name 45 | * - cursor 46 | * - stringify_ids (0|1) 47 | * - count (1-5000) 48 | * 49 | * @param mixed $parameters 50 | */ 51 | public function getFollowersIds($parameters = []) 52 | { 53 | return $this->get('followers/ids', $parameters); 54 | } 55 | 56 | /** 57 | * Returns a collection of numeric IDs for every user who has a pending request to follow the authenticating user. 58 | * 59 | * Parameters : 60 | * - cursor 61 | * - stringify_ids (0|1) 62 | * 63 | * @param mixed $parameters 64 | */ 65 | public function getFriendshipsIn($parameters = []) 66 | { 67 | return $this->get('friendships/incoming', $parameters); 68 | } 69 | 70 | /** 71 | * Returns a collection of numeric IDs for every protected user for whom the authenticating user has a pending follow request. 72 | * 73 | * Parameters : 74 | * - cursor 75 | * - stringify_ids (0|1) 76 | * 77 | * @param mixed $parameters 78 | */ 79 | public function getFriendshipsOut($parameters = []) 80 | { 81 | return $this->get('friendships/outgoing', $parameters); 82 | } 83 | 84 | /** 85 | * Allows the authenticating users to follow the user specified in the ID parameter. 86 | * 87 | * Parameters : 88 | * - screen_name 89 | * - user_id 90 | * - follow (0|1) 91 | * 92 | * @param mixed $parameters 93 | */ 94 | public function postFollow($parameters = []) 95 | { 96 | if (!array_key_exists('screen_name', $parameters) && !array_key_exists('user_id', $parameters)) { 97 | throw new BadMethodCallException('Parameter required missing : screen_name or user_id'); 98 | } 99 | 100 | return $this->post('friendships/create', $parameters); 101 | } 102 | 103 | /** 104 | * Allows the authenticating user to unfollow the user specified in the ID parameter. 105 | * 106 | * Parameters : 107 | * - screen_name 108 | * - user_id 109 | * 110 | * @param mixed $parameters 111 | */ 112 | public function postUnfollow($parameters = []) 113 | { 114 | if (!array_key_exists('screen_name', $parameters) && !array_key_exists('user_id', $parameters)) { 115 | throw new BadMethodCallException('Parameter required missing : screen_name or user_id'); 116 | } 117 | 118 | return $this->post('friendships/destroy', $parameters); 119 | } 120 | 121 | /** 122 | * Allows one to enable or disable retweets and device notifications from the specified user. 123 | * 124 | * Parameters : 125 | * - screen_name 126 | * - user_id 127 | * - device (0|1) 128 | * - retweets (0|1) 129 | * 130 | * @param mixed $parameters 131 | */ 132 | public function postFollowUpdate($parameters = []) 133 | { 134 | if (!array_key_exists('screen_name', $parameters) && !array_key_exists('user_id', $parameters)) { 135 | throw new BadMethodCallException('Parameter required missing : screen_name or user_id'); 136 | } 137 | 138 | return $this->post('friendships/update', $parameters); 139 | } 140 | 141 | /** 142 | * Returns detailed information about the relationship between two arbitrary users. 143 | * 144 | * Parameters : 145 | * - source_id 146 | * - source_screen_name 147 | * - target_id 148 | * - target_screen_name 149 | * 150 | * @param mixed $parameters 151 | */ 152 | public function getFriendships($parameters = []) 153 | { 154 | if (!array_key_exists('target_id', $parameters) && !array_key_exists('target_screen_name', $parameters)) { 155 | throw new BadMethodCallException('Parameter required missing : target_id or target_screen_name'); 156 | } 157 | 158 | return $this->get('friendships/show', $parameters); 159 | } 160 | 161 | /** 162 | * Returns a cursored collection of user objects for every user the specified user is following (otherwise known as their “friends”). 163 | * 164 | * Parameters : 165 | * - user_id 166 | * - screen_name 167 | * - cursor 168 | * - skip_status (0|1) 169 | * - include_user_entities (0|1) 170 | * 171 | * @param mixed $parameters 172 | */ 173 | public function getFriends($parameters = []) 174 | { 175 | return $this->get('friends/list', $parameters); 176 | } 177 | 178 | /** 179 | * Returns a cursored collection of user objects for users following the specified user. 180 | * 181 | * Parameters : 182 | * - user_id 183 | * - screen_name 184 | * - cursor 185 | * - skip_status (0|1) 186 | * - include_user_entities (0|1) 187 | * 188 | * @param mixed $parameters 189 | */ 190 | public function getFollowers($parameters = []) 191 | { 192 | return $this->get('followers/list', $parameters); 193 | } 194 | 195 | /** 196 | * Returns the relationships of the authenticating user to the comma-separated list of up to 100 screen_names or user_ids provided. Values for connections can be: following, following_requested, followed_by, none, blocking, muting. 197 | * 198 | * Parameters : 199 | * - screen_name 200 | * - user_id 201 | * 202 | * @param mixed $parameters 203 | */ 204 | public function getFriendshipsLookup($parameters = []) 205 | { 206 | return $this->get('friendships/lookup', $parameters); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/ApiV1/Traits/GeoTrait.php: -------------------------------------------------------------------------------- 1 | get('geo/id/' . $id); 17 | } 18 | 19 | /** 20 | * Given a latitude and a longitude, searches for up to 20 places that can be used as a place_id when updating a status. 21 | * 22 | * Parameters : 23 | * - lat 24 | * - long 25 | * - accuracy 26 | * - granularity (poi|neighborhood|city|admin|country) 27 | * - max_results 28 | * - callback 29 | * 30 | * @param mixed $parameters 31 | */ 32 | public function getGeoReverse($parameters = []) 33 | { 34 | if (!array_key_exists('lat', $parameters) || !array_key_exists('long', $parameters)) { 35 | throw new BadMethodCallException('Parameter required missing : lat or long'); 36 | } 37 | 38 | return $this->get('geo/reverse_geocode', $parameters); 39 | } 40 | 41 | /** 42 | * Search for places that can be attached to a statuses/update. Given a latitude and a longitude pair, an IP address, or a name, this request will return a list of all the valid places that can be used as the place_id when updating a status. 43 | * 44 | * Parameters : 45 | * - lat 46 | * - long 47 | * - query 48 | * - ip 49 | * - granularity (poi|neighborhood|city|admin|country) 50 | * - accuracy 51 | * - max_results 52 | * - contained_within 53 | * - attribute:street_address 54 | * - callback 55 | * 56 | * @param mixed $parameters 57 | */ 58 | public function getGeoSearch($parameters = []) 59 | { 60 | return $this->get('geo/search', $parameters); 61 | } 62 | 63 | /** 64 | * Locates places near the given coordinates which are similar in name. Conceptually you would use this method to get a list of known places to choose from first. Then, if the desired place doesn't exist, make a request to POST geo/place to create a new one. The token contained in the response is the token needed to be able to create a new place. 65 | * 66 | * Parameters : 67 | * - lat 68 | * - long 69 | * - name 70 | * - contained_within 71 | * - attribute:street_address 72 | * - callback 73 | * 74 | * @param mixed $parameters 75 | */ 76 | public function getGeoSimilar($parameters = []) 77 | { 78 | if (!array_key_exists('lat', $parameters) || !array_key_exists('long', $parameters) || !array_key_exists('name', $parameters)) { 79 | throw new BadMethodCallException('Parameter required missing : lat, long or name'); 80 | } 81 | 82 | return $this->get('geo/similar_places', $parameters); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/ApiV1/Traits/HelpTrait.php: -------------------------------------------------------------------------------- 1 | post('users/report_spam', $parameters); 25 | } 26 | 27 | /** 28 | * Returns the current configuration used by Twitter including twitter.com slugs which are not usernames, maximum photo resolutions, and t.co URL lengths. 29 | * 30 | * @param mixed $parameters 31 | */ 32 | public function getHelpConfiguration($parameters = []) 33 | { 34 | return $this->get('help/configuration', $parameters); 35 | } 36 | 37 | /** 38 | * Returns the list of languages supported by Twitter along with the language code supported by Twitter. 39 | * 40 | * @param mixed $parameters 41 | */ 42 | public function getHelpLanguages($parameters = []) 43 | { 44 | return $this->get('help/languages', $parameters); 45 | } 46 | 47 | /** 48 | * Returns Twitter’s Privacy Policy. 49 | * 50 | * @param mixed $parameters 51 | */ 52 | public function getHelpPrivacy($parameters = []) 53 | { 54 | return $this->get('help/privacy', $parameters); 55 | } 56 | 57 | /** 58 | * Returns the Twitter Terms of Service. Note: these are not the same as the Developer Policy. 59 | * 60 | * @param mixed $parameters 61 | */ 62 | public function getHelpTos($parameters = []) 63 | { 64 | return $this->get('help/tos', $parameters); 65 | } 66 | 67 | /** 68 | * Returns the current rate limits for methods belonging to the specified resource families. 69 | * 70 | * @param mixed $parameters 71 | */ 72 | public function getAppRateLimit($parameters = []) 73 | { 74 | return $this->get('application/rate_limit_status', $parameters); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/ApiV1/Traits/MediaTrait.php: -------------------------------------------------------------------------------- 1 | post('media/upload', $this->normalizeParameters($parameters), true); 35 | } 36 | 37 | private function normalizeParameters(array $parameters): array 38 | { 39 | $normalizedParams = []; 40 | $nameKey = 'name'; 41 | $contentsKey = 'contents'; 42 | 43 | foreach ($parameters as $key => $value) { 44 | if (is_array($value) && isset($value[$nameKey], $value[$contentsKey])) { 45 | $normalizedParams[] = $value; 46 | 47 | continue; 48 | } 49 | 50 | if (!is_array($value)) { 51 | $normalizedParams[] = [ 52 | $nameKey => $key, 53 | $contentsKey => $value, 54 | ]; 55 | } 56 | } 57 | 58 | return $normalizedParams; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/ApiV1/Traits/SearchTrait.php: -------------------------------------------------------------------------------- 1 | get('search/tweets', $parameters); 34 | } 35 | 36 | /** 37 | * Returns the authenticated user’s saved search queries. 38 | */ 39 | public function getSavedSearches() 40 | { 41 | return $this->get('saved_searches/list'); 42 | } 43 | 44 | /** 45 | * Retrieve the information for the saved search represented by the given id. The authenticating user must be the owner of saved search ID being requested. 46 | * 47 | * @param mixed $id 48 | */ 49 | public function getSavedSearch($id) 50 | { 51 | return $this->get('saved_searches/show/' . $id); 52 | } 53 | 54 | /** 55 | * Create a new saved search for the authenticated user. A user may only have 25 saved searches. 56 | * 57 | * Parameters : 58 | * - query 59 | * 60 | * @param mixed $parameters 61 | */ 62 | public function postSavedSearch($parameters = []) 63 | { 64 | if (!array_key_exists('query', $parameters)) { 65 | throw new BadMethodCallException('Parameter required missing : query'); 66 | } 67 | 68 | return $this->post('saved_searches/create', $parameters); 69 | } 70 | 71 | /** 72 | * Destroys a saved search for the authenticating user. The authenticating user must be the owner of saved search id being destroyed. 73 | * 74 | * @param mixed $id 75 | * @param mixed $parameters 76 | */ 77 | public function destroySavedSearch($id, $parameters = []) 78 | { 79 | return $this->post('saved_searches/destroy/' . $id, $parameters); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/ApiV1/Traits/StatusTrait.php: -------------------------------------------------------------------------------- 1 | get('statuses/mentions_timeline', $parameters); 27 | } 28 | 29 | /** 30 | * Returns a collection of the most recent Tweets (truncated by default) posted by the user indicated by the screen_name or user_id parameters. 31 | * 32 | * Parameters : 33 | * - user_id 34 | * - screen_name 35 | * - since_id 36 | * - count (1-200) 37 | * - include_rts (0|1) 38 | * - max_id 39 | * - trim_user (0|1) 40 | * - exclude_replies (0|1) 41 | * - contributor_details (0|1) 42 | * - include_entities (0|1) 43 | * - tweet_mode ('extended' returns a collection of Tweets, which are not truncated) 44 | * 45 | * @param mixed $parameters 46 | */ 47 | public function getUserTimeline($parameters = []) 48 | { 49 | return $this->get('statuses/user_timeline', $parameters); 50 | } 51 | 52 | /** 53 | * Returns a collection of the most recent Tweets (truncated by default) and retweets posted by the authenticating user and the users they follow. The home timeline is central to how most users interact with the Twitter service. 54 | * 55 | * Parameters : 56 | * - count (1-200) 57 | * - since_id 58 | * - max_id 59 | * - trim_user (0|1) 60 | * - exclude_replies (0|1) 61 | * - contributor_details (0|1) 62 | * - include_entities (0|1) 63 | * - tweet_mode ('extended' returns a collection of Tweets, which are not truncated) 64 | * 65 | * @param mixed $parameters 66 | */ 67 | public function getHomeTimeline($parameters = []) 68 | { 69 | return $this->get('statuses/home_timeline', $parameters); 70 | } 71 | 72 | /** 73 | * Returns the most recent tweets authored by the authenticating user that have been retweeted by others. 74 | * 75 | * Parameters : 76 | * - count (1-200) 77 | * - since_id 78 | * - max_id 79 | * - trim_user (0|1) 80 | * - include_entities (0|1) 81 | * - include_user_entities (0|1) 82 | * - tweet_mode ('extended' returns a collection of Tweets, which are not truncated) 83 | * 84 | * @param mixed $parameters 85 | */ 86 | public function getRtsTimeline($parameters = []) 87 | { 88 | return $this->get('statuses/retweets_of_me', $parameters); 89 | } 90 | 91 | /** 92 | * Returns a collection of the 100 most recent retweets of the tweet specified by the id parameter. 93 | * 94 | * Parameters : 95 | * - count (1-200) 96 | * - trim_user (0|1) 97 | * 98 | * @param mixed $id 99 | * @param mixed $parameters 100 | */ 101 | public function getRts($id, $parameters = []) 102 | { 103 | return $this->get('statuses/retweets/' . $id, $parameters); 104 | } 105 | 106 | /** 107 | * Returns a single Tweet, specified by the id parameter. The Tweet’s author will also be embedded within the tweet. 108 | * 109 | * Parameters : 110 | * - count (1-200) 111 | * - trim_user (0|1) 112 | * - include_my_retweet (0|1) 113 | * - include_entities (0|1) 114 | * - tweet_mode ('extended' returns a collection of Tweets, which are not truncated) 115 | * 116 | * @param mixed $id 117 | * @param mixed $parameters 118 | */ 119 | public function getTweet($id, $parameters = []) 120 | { 121 | return $this->get('statuses/show/' . $id, $parameters); 122 | } 123 | 124 | /** 125 | * Destroys the status specified by the required ID parameter. The authenticating user must be the author of the specified status. Returns the destroyed status if successful. 126 | * 127 | * Parameters : 128 | * - trim_user (0|1) 129 | * 130 | * @param mixed $id 131 | * @param mixed $parameters 132 | */ 133 | public function destroyTweet($id, $parameters = []) 134 | { 135 | return $this->post('statuses/destroy/' . $id, $parameters); 136 | } 137 | 138 | /** 139 | * Updates the authenticating user’s current status, also known as tweeting. 140 | * 141 | * Parameters : 142 | * - status 143 | * - in_reply_to_status_id 144 | * - lat 145 | * - long 146 | * - place_id 147 | * - display_coordinates (0|1) 148 | * - trim_user (0|1) 149 | * - media_ids 150 | * 151 | * @param mixed $parameters 152 | */ 153 | public function postTweet($parameters = []) 154 | { 155 | if (!array_key_exists('status', $parameters)) { 156 | throw new BadMethodCallException('Parameter required missing : status'); 157 | } 158 | 159 | return $this->post('statuses/update', $parameters); 160 | } 161 | 162 | /** 163 | * Retweets a tweet. Returns the original tweet with retweet details embedded. 164 | * 165 | * Parameters : 166 | * - trim_user (0|1) 167 | * 168 | * @param mixed $id 169 | * @param mixed $parameters 170 | */ 171 | public function postRt($id, $parameters = []) 172 | { 173 | return $this->post('statuses/retweet/' . $id, $parameters); 174 | } 175 | 176 | /** 177 | * Updates the authenticating user’s current status and attaches media for upload. In other words, it creates a Tweet with a picture attached. 178 | * DEPRECATED. 179 | * 180 | * Parameters : 181 | * - status 182 | * - media[] 183 | * - possibly_sensitive 184 | * - in_reply_to_status_id 185 | * - lat 186 | * - long 187 | * - place_id 188 | * - display_coordinates (0|1) 189 | * 190 | * @param mixed $parameters 191 | */ 192 | public function postTweetMedia($parameters = []) 193 | { 194 | if (!array_key_exists('status', $parameters) || !array_key_exists('media[]', $parameters)) { 195 | throw new BadMethodCallException('Parameter required missing : status or media[]'); 196 | } 197 | 198 | return $this->post('statuses/update_with_media', $parameters, true); 199 | } 200 | 201 | /** 202 | * Returns a single Tweet, specified by a Tweet web URL, in an oEmbed-compatible format. The returned HTML snippet will be automatically recognized as an Embedded Tweet when Twitter’s widget JavaScript is included on the page. 203 | * 204 | * Parameters : 205 | * - url 206 | * - maxwidth (250-550) 207 | * - hide_thread (0|1) 208 | * - omit_script (0|1) 209 | * - align (left|right|center|none) 210 | * - related (twitterapi|twittermedia|twitter) 211 | * - lang 212 | * - theme (dark|light) 213 | * - link_color (hex value) 214 | * - widget_type (video) 215 | * 216 | * @param mixed $parameters 217 | */ 218 | public function getOembed($parameters = []) 219 | { 220 | if (!array_key_exists('url', $parameters)) { 221 | throw new BadMethodCallException('Parameter required missing : url'); 222 | } 223 | 224 | return $this->directQuery('https://publish.twitter.com/oembed', 'GET', $parameters); 225 | } 226 | 227 | /** 228 | * Returns a collection of up to 100 user IDs belonging to users who have retweeted the tweet specified by the id parameter. 229 | * 230 | * Parameters : 231 | * - id 232 | * - cursor 233 | * - stringify_ids (0|1) 234 | * 235 | * @param mixed $parameters 236 | */ 237 | public function getRters($parameters = []) 238 | { 239 | if (!array_key_exists('id', $parameters)) { 240 | throw new BadMethodCallException('Parameter required missing : id'); 241 | } 242 | 243 | return $this->get('statuses/retweeters/ids', $parameters); 244 | } 245 | 246 | /** 247 | * Returns fully-hydrated tweet objects for up to 100 tweets per request, as specified by comma-separated values passed to the id parameter. 248 | * 249 | * Parameters : 250 | * - id 251 | * - include_entities (0|1) 252 | * - trim_user (0|1) 253 | * - map (0|1) 254 | * - tweet_mode ('extended' returns a collection of Tweets, which are not truncated) 255 | * 256 | * @param mixed $parameters 257 | */ 258 | public function getStatusesLookup($parameters = []) 259 | { 260 | if (!array_key_exists('id', $parameters)) { 261 | throw new BadMethodCallException('Parameter required missing : id'); 262 | } 263 | 264 | return $this->get('statuses/lookup', $parameters); 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /src/ApiV1/Traits/TrendTrait.php: -------------------------------------------------------------------------------- 1 | get('trends/place', $parameters); 25 | } 26 | 27 | /** 28 | * Returns the locations that Twitter has trending topic information for. 29 | * 30 | * @param mixed $parameters 31 | */ 32 | public function getTrendsAvailable($parameters = []) 33 | { 34 | return $this->get('trends/available', $parameters); 35 | } 36 | 37 | /** 38 | * Returns the locations that Twitter has trending topic information for, closest to a specified location. 39 | * 40 | * Parameters : 41 | * - lat 42 | * - long 43 | * 44 | * @param mixed $parameters 45 | */ 46 | public function getTrendsClosest($parameters = []) 47 | { 48 | if (!array_key_exists('lat', $parameters) || !array_key_exists('long', $parameters)) { 49 | throw new BadMethodCallException('Parameter required missing : lat or long'); 50 | } 51 | 52 | return $this->get('trends/closest', $parameters); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/ApiV1/Traits/UserTrait.php: -------------------------------------------------------------------------------- 1 | get('users/lookup', $parameters); 26 | } 27 | 28 | /** 29 | * Returns a variety of information about the user specified by the required user_id or screen_name parameter. The author’s most recent Tweet will be returned inline when possible. 30 | * 31 | * Parameters : 32 | * - user_id 33 | * - screen_name 34 | * - include_entities (0|1) 35 | * 36 | * @param mixed $parameters 37 | */ 38 | public function getUsers($parameters = []) 39 | { 40 | if (!array_key_exists('user_id', $parameters) && !array_key_exists('screen_name', $parameters)) { 41 | throw new BadMethodCallException('Parameter required missing : user_id or screen_name'); 42 | } 43 | 44 | return $this->get('users/show', $parameters); 45 | } 46 | 47 | /** 48 | * Provides a simple, relevance-based search interface to public user accounts on Twitter. Try querying by topical interest, full name, company name, location, or other criteria. Exact match searches are not supported. 49 | * 50 | * Parameters : 51 | * - q 52 | * - page 53 | * - count 54 | * - include_entities (0|1) 55 | * 56 | * @param mixed $parameters 57 | */ 58 | public function getUsersSearch($parameters = []) 59 | { 60 | if (!array_key_exists('q', $parameters)) { 61 | throw new BadMethodCallException('Parameter required missing : q'); 62 | } 63 | 64 | return $this->get('users/search', $parameters); 65 | } 66 | 67 | /** 68 | * Returns a map of the available size variations of the specified user’s profile banner. If the user has not uploaded a profile banner, a HTTP 404 will be served instead. This method can be used instead of string manipulation on the profile_banner_url returned in user objects as described in Profile Images and Banners. 69 | * 70 | * Parameters : 71 | * - user_id 72 | * - screen_name 73 | * 74 | * @param mixed $parameters 75 | */ 76 | public function getUserBanner($parameters = []) 77 | { 78 | return $this->get('users/profile_banner', $parameters); 79 | } 80 | 81 | /** 82 | * Mutes the user specified in the ID parameter for the authenticating user. 83 | * 84 | * Parameters : 85 | * - screen_name 86 | * - user_id 87 | * 88 | * @param mixed $parameters 89 | */ 90 | public function muteUser($parameters = []) 91 | { 92 | if (!array_key_exists('screen_name', $parameters) && !array_key_exists('user_id', $parameters)) { 93 | throw new BadMethodCallException('Parameter required missing : screen_name or user_id'); 94 | } 95 | 96 | return $this->post('mutes/users/create', $parameters); 97 | } 98 | 99 | /** 100 | * Un-mutes the user specified in the ID parameter for the authenticating user. 101 | * 102 | * Parameters : 103 | * - screen_name 104 | * - user_id 105 | * 106 | * @param mixed $parameters 107 | */ 108 | public function unmuteUser($parameters = []) 109 | { 110 | if (!array_key_exists('screen_name', $parameters) && !array_key_exists('user_id', $parameters)) { 111 | throw new BadMethodCallException('Parameter required missing : screen_name or user_id'); 112 | } 113 | 114 | return $this->post('mutes/users/destroy', $parameters); 115 | } 116 | 117 | /** 118 | * Returns an array of numeric user ids the authenticating user has muted. 119 | * 120 | * Parameters : 121 | * - cursor 122 | * 123 | * @param mixed $parameters 124 | */ 125 | public function mutedUserIds($parameters = []) 126 | { 127 | return $this->get('mutes/users/ids', $parameters); 128 | } 129 | 130 | /** 131 | * Returns an array of user objects the authenticating user has muted. 132 | * 133 | * Parameters : 134 | * - cursor 135 | * - include_entities 136 | * - skip_status 137 | * 138 | * @param mixed $parameters 139 | */ 140 | public function mutedUsers($parameters = []) 141 | { 142 | return $this->get('mutes/users/list', $parameters); 143 | } 144 | 145 | /** 146 | * Access the users in a given category of the Twitter suggested user list. 147 | * 148 | * Parameters : 149 | * - lang 150 | * 151 | * @param mixed $slug 152 | * @param mixed $parameters 153 | */ 154 | public function getSuggesteds($slug, $parameters = []) 155 | { 156 | return $this->get('users/suggestions/' . $slug, $parameters); 157 | } 158 | 159 | /** 160 | * Access to Twitter’s suggested user list. This returns the list of suggested user categories. The category can be used in GET users / suggestions / :slug to get the users in that category. 161 | * 162 | * Parameters : 163 | * - lang 164 | * 165 | * @param mixed $parameters 166 | */ 167 | public function getSuggestions($parameters = []) 168 | { 169 | return $this->get('users/suggestions', $parameters); 170 | } 171 | 172 | /** 173 | * Access the users in a given category of the Twitter suggested user list and return their most recent status if they are not a protected user. 174 | * 175 | * @param mixed $slug 176 | * @param mixed $parameters 177 | */ 178 | public function getSuggestedsMembers($slug, $parameters = []) 179 | { 180 | return $this->get('users/suggestions/' . $slug . '/members', $parameters); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/Concern/ApiV2Behavior.php: -------------------------------------------------------------------------------- 1 | Twitter::RESPONSE_FORMAT_JSON]; 25 | 26 | return array_merge($defaults, $additionalParams); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Concern/FilteredStream.php: -------------------------------------------------------------------------------- 1 | getQuerier() 23 | ->getStream('tweets/search/stream', $onTweet, $parameters); 24 | } 25 | 26 | /** 27 | * @throws ClientException 28 | * 29 | * @see https://developer.twitter.com/en/docs/twitter-api/tweets/filtered-stream/api-reference/get-tweets-search-stream-rules 30 | */ 31 | public function getStreamRules(array $queryParameters) 32 | { 33 | return $this->getQuerier() 34 | ->withOAuth2Client() 35 | ->get('tweets/search/stream/rules', $this->withDefaultParams($queryParameters)); 36 | } 37 | 38 | /** 39 | * @throws ClientException 40 | * 41 | * @see https://developer.twitter.com/en/docs/twitter-api/tweets/filtered-stream/api-reference/post-tweets-search-stream-rules 42 | */ 43 | public function postStreamRules(array $parameters) 44 | { 45 | $parameters[Twitter::KEY_REQUEST_FORMAT] = Twitter::REQUEST_FORMAT_JSON; 46 | 47 | return $this->getQuerier() 48 | ->withOAuth2Client() 49 | ->post('tweets/search/stream/rules', $this->withDefaultParams($parameters)); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Concern/Follows.php: -------------------------------------------------------------------------------- 1 | getQuerier() 22 | ->withOAuth2Client() 23 | ->get(sprintf('users/%s/following', $userId), $this->withDefaultParams($queryParameters)); 24 | } 25 | 26 | /** 27 | * @throws ClientException 28 | * 29 | * @see https://developer.twitter.com/en/docs/twitter-api/users/follows/api-reference/get-users-id-followers 30 | */ 31 | public function getFollowers(string $userId, array $queryParameters) 32 | { 33 | return $this->getQuerier() 34 | ->withOAuth2Client() 35 | ->get(sprintf('users/%s/followers', $userId), $this->withDefaultParams($queryParameters)); 36 | } 37 | 38 | /** 39 | * @throws ClientException 40 | * 41 | * @see https://developer.twitter.com/en/docs/twitter-api/users/follows/api-reference/post-users-source_user_id-following 42 | */ 43 | public function follow(string $sourceUserId, string $targetUserId) 44 | { 45 | $parameters = [ 46 | 'target_user_id' => $targetUserId, 47 | Twitter::KEY_REQUEST_FORMAT => Twitter::REQUEST_FORMAT_JSON, 48 | ]; 49 | 50 | return $this->getQuerier() 51 | ->withOAuth1Client() 52 | ->post(sprintf('users/%s/following', $sourceUserId), $this->withDefaultParams($parameters)); 53 | } 54 | 55 | /** 56 | * @throws ClientException 57 | * 58 | * @see https://developer.twitter.com/en/docs/twitter-api/users/follows/api-reference/delete-users-source_id-following 59 | */ 60 | public function unfollow(string $sourceUserId, string $targetUserId) 61 | { 62 | return $this->getQuerier() 63 | ->withOAuth1Client() 64 | ->delete(sprintf('users/%s/following/%s', $sourceUserId, $targetUserId), $this->withDefaultParams()); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Concern/HideReplies.php: -------------------------------------------------------------------------------- 1 | $hidden, 23 | Twitter::KEY_REQUEST_FORMAT => Twitter::REQUEST_FORMAT_JSON, 24 | ]; 25 | 26 | return $this->getQuerier() 27 | ->put(sprintf('tweets/%s/hidden', $tweetId), $this->withDefaultParams($parameters)); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Concern/HotSwapper.php: -------------------------------------------------------------------------------- 1 | setQuerier( 30 | $this->getQuerier() 31 | ->usingCredentials( 32 | $accessToken, 33 | $accessTokenSecret, 34 | $consumerKey, 35 | $consumerSecret 36 | ) 37 | ); 38 | } 39 | 40 | /** 41 | * @throws InvalidArgumentException 42 | */ 43 | public function usingConfiguration(Configuration $configuration): TwitterBaseContract 44 | { 45 | return $this->setQuerier( 46 | $this->getQuerier() 47 | ->usingConfiguration($configuration) 48 | ); 49 | } 50 | 51 | /** 52 | * @throws InvalidArgumentException 53 | */ 54 | public function forApiV1(): TwitterV1Contract 55 | { 56 | $config = $this->getQuerier() 57 | ->getConfiguration() 58 | ->forApiV1(); 59 | 60 | return new TwitterV1( 61 | $this->getQuerier() 62 | ->usingConfiguration($config) 63 | ); 64 | } 65 | 66 | /** 67 | * @throws InvalidArgumentException 68 | */ 69 | public function forApiV2(): TwitterV2Contract 70 | { 71 | $config = $this->getQuerier() 72 | ->getConfiguration() 73 | ->forApiV2(); 74 | 75 | return new TwitterV2( 76 | $this->getQuerier() 77 | ->usingConfiguration($config) 78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Concern/SampledStream.php: -------------------------------------------------------------------------------- 1 | getQuerier() 22 | ->getStream('tweets/sample/stream', $onTweet, $parameters); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Concern/SearchTweets.php: -------------------------------------------------------------------------------- 1 | $query]); 21 | 22 | return $this->getQuerier() 23 | ->get('tweets/search/recent', $this->withDefaultParams($queryParameters)); 24 | } 25 | 26 | /** 27 | * @throws ClientException 28 | * 29 | * @see https://developer.twitter.com/en/docs/twitter-api/tweets/search/api-reference/get-tweets-search-all 30 | */ 31 | public function searchAll(string $query, array $additionalParameters) 32 | { 33 | $queryParameters = array_merge($additionalParameters, ['query' => $query]); 34 | 35 | return $this->getQuerier() 36 | ->withOAuth2Client() 37 | ->get('tweets/search/all', $this->withDefaultParams($queryParameters)); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Concern/Timelines.php: -------------------------------------------------------------------------------- 1 | getQuerier() 21 | ->get(sprintf('users/%s/tweets', $userId), $this->withDefaultParams($queryParameters)); 22 | } 23 | 24 | /** 25 | * @throws ClientException 26 | * 27 | * @see https://developer.twitter.com/en/docs/twitter-api/tweets/timelines/api-reference/get-users-id-mentions 28 | */ 29 | public function userMentions(string $userId, array $queryParameters) 30 | { 31 | return $this->getQuerier() 32 | ->get(sprintf('users/%s/mentions', $userId), $this->withDefaultParams($queryParameters)); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Concern/TweetCounts.php: -------------------------------------------------------------------------------- 1 | $query]); 21 | 22 | return $this->getQuerier() 23 | ->withOAuth2Client() 24 | ->get('tweets/counts/recent', $this->withDefaultParams($queryParameters)); 25 | } 26 | 27 | /** 28 | * @throws ClientException 29 | * 30 | * @see https://developer.twitter.com/en/docs/twitter-api/tweets/counts/api-reference/get-tweets-counts-all 31 | */ 32 | public function countAll(string $query, array $additionalParameters = []) 33 | { 34 | $queryParameters = array_merge($additionalParameters, ['query' => $query]); 35 | 36 | return $this->getQuerier() 37 | ->withOAuth2Client() 38 | ->get('tweets/counts/all', $this->withDefaultParams($queryParameters)); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Concern/TweetLookup.php: -------------------------------------------------------------------------------- 1 | getQuerier() 21 | ->get(sprintf('tweets/%s', $tweetId), $this->withDefaultParams($queryParameters)); 22 | } 23 | 24 | /** 25 | * @throws ClientException 26 | * 27 | * @see https://developer.twitter.com/en/docs/twitter-api/tweets/lookup/api-reference/get-tweets 28 | */ 29 | public function getTweets(array $tweetIds, array $additionalParameters) 30 | { 31 | $queryParameters = array_merge($additionalParameters, ['ids' => $this->implodeParamValues($tweetIds)]); 32 | 33 | return $this->getQuerier() 34 | ->get('tweets', $this->withDefaultParams($queryParameters)); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Concern/UserLookup.php: -------------------------------------------------------------------------------- 1 | getQuerier() 21 | ->get(sprintf('users/%s', $userId), $this->withDefaultParams($queryParameters)); 22 | } 23 | 24 | /** 25 | * @throws ClientException 26 | * 27 | * @see https://developer.twitter.com/en/docs/twitter-api/users/lookup/api-reference/get-users 28 | */ 29 | public function getUsers(array $userIds, array $additionalParameters) 30 | { 31 | $queryParameters = array_merge($additionalParameters, ['ids' => $this->implodeParamValues($userIds)]); 32 | 33 | return $this->getQuerier() 34 | ->get('users', $this->withDefaultParams($queryParameters)); 35 | } 36 | 37 | /** 38 | * @throws ClientException 39 | * 40 | * @see https://developer.twitter.com/en/docs/twitter-api/users/lookup/api-reference/get-users-by-username-username 41 | */ 42 | public function getUserByUsername(string $username, array $queryParameters) 43 | { 44 | return $this->getQuerier() 45 | ->get(sprintf('users/by/username/%s', $username), $this->withDefaultParams($queryParameters)); 46 | } 47 | 48 | /** 49 | * @throws ClientException 50 | * 51 | * @see https://developer.twitter.com/en/docs/twitter-api/users/lookup/api-reference/get-users 52 | */ 53 | public function getUsersByUsernames(array $usernames, array $additionalParameters) 54 | { 55 | $queryParameters = array_merge($additionalParameters, ['usernames' => $this->implodeParamValues($usernames)]); 56 | 57 | return $this->getQuerier() 58 | ->get('users/by', $this->withDefaultParams($queryParameters)); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Configuration.php: -------------------------------------------------------------------------------- 1 | apiUrl = $apiUrl; 42 | $this->uploadUrl = $uploadUrl; 43 | $this->apiVersion = $apiVersion; 44 | 45 | $this->consumerKey = $consumerKey; 46 | $this->consumerSecret = $consumerSecret; 47 | $this->accessToken = $accessToken; 48 | $this->accessTokenSecret = $accessTokenSecret; 49 | 50 | $this->authenticateUrl = $authenticateUrl; 51 | $this->accessTokenUrl = $accessTokenUrl; 52 | $this->requestTokenUrl = $requestTokenUrl; 53 | 54 | $this->debugMode = $debugMode; 55 | $this->userAgent = $userAgent ?? sprintf('%s v%s php v%s', self::PACKAGE_NAME, Twitter::VERSION, PHP_VERSION); 56 | } 57 | 58 | /** 59 | * @throws InvalidConfigException 60 | */ 61 | public static function createFromConfig(array $config): self 62 | { 63 | if (!isset($config[self::KEY_API_URL], $config[self::KEY_UPLOAD_URL], $config[self::KEY_API_VERSION])) { 64 | throw new InvalidConfigException('Required configuration options missing!'); 65 | } 66 | 67 | return new self( 68 | $config[self::KEY_API_URL], 69 | $config[self::KEY_UPLOAD_URL], 70 | $config[self::KEY_API_VERSION], 71 | $config[self::KEY_CONSUMER_KEY], 72 | $config[self::KEY_CONSUMER_SECRET], 73 | $config[self::KEY_ACCESS_TOKEN], 74 | $config[self::KEY_ACCESS_TOKEN_SECRET], 75 | $config[self::KEY_AUTHENTICATE_URL], 76 | $config[self::KEY_ACCESS_TOKEN_URL], 77 | $config[self::KEY_REQUEST_TOKEN_URL], 78 | $config[self::KEY_DEBUG] 79 | ); 80 | } 81 | 82 | public function forApiV1(): self 83 | { 84 | $instance = clone $this; 85 | $instance->apiVersion = Twitter::API_VERSION_1; 86 | 87 | return $instance; 88 | } 89 | 90 | public function forApiV2(): self 91 | { 92 | $instance = clone $this; 93 | $instance->apiVersion = Twitter::API_VERSION_2; 94 | 95 | return $instance; 96 | } 97 | 98 | public function withOauthCredentials( 99 | string $accessToken, 100 | string $accessTokenSecret, 101 | ?string $consumerKey = null, 102 | ?string $consumerSecret = null 103 | ): self { 104 | $config = clone $this; 105 | $config->accessToken = $accessToken; 106 | $config->accessTokenSecret = $accessTokenSecret; 107 | $config->consumerKey = $consumerKey ?? $config->consumerKey; 108 | $config->consumerSecret = $consumerSecret ?? $config->consumerSecret; 109 | 110 | return $config; 111 | } 112 | 113 | public function withoutOauthCredentials(bool $removeConsumerCredentials = false): self 114 | { 115 | $config = clone $this; 116 | $config->accessToken = null; 117 | $config->accessTokenSecret = null; 118 | 119 | if ($removeConsumerCredentials) { 120 | $config->consumerKey = null; 121 | $config->consumerSecret = null; 122 | } 123 | 124 | return $config; 125 | } 126 | 127 | public function getApiUrl(): string 128 | { 129 | return $this->apiUrl; 130 | } 131 | 132 | public function getUploadUrl(): string 133 | { 134 | return $this->uploadUrl; 135 | } 136 | 137 | public function getApiVersion(): string 138 | { 139 | return $this->apiVersion; 140 | } 141 | 142 | public function getConsumerKey(): ?string 143 | { 144 | return $this->consumerKey; 145 | } 146 | 147 | public function getConsumerSecret(): ?string 148 | { 149 | return $this->consumerSecret; 150 | } 151 | 152 | public function getAccessToken(): ?string 153 | { 154 | return $this->accessToken; 155 | } 156 | 157 | public function getAccessTokenSecret(): ?string 158 | { 159 | return $this->accessTokenSecret; 160 | } 161 | 162 | public function getAuthenticateUrl(): ?string 163 | { 164 | return $this->authenticateUrl; 165 | } 166 | 167 | public function getAccessTokenUrl(): ?string 168 | { 169 | return $this->accessTokenUrl; 170 | } 171 | 172 | public function getRequestTokenUrl(): ?string 173 | { 174 | return $this->requestTokenUrl; 175 | } 176 | 177 | public function isDebugMode(): bool 178 | { 179 | return $this->debugMode; 180 | } 181 | 182 | public function getUserAgent(): ?string 183 | { 184 | return $this->userAgent; 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/Contract/Configuration.php: -------------------------------------------------------------------------------- 1 | getResponseBody(); 23 | 24 | try { 25 | if (is_array($responseBody)) { 26 | $responseBody = json_encode($responseBody, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); 27 | } 28 | } catch (JsonException $exception) { 29 | return new self( 30 | sprintf('Authentication failed with message: %s', $identityProviderException->getMessage()) 31 | ); 32 | } 33 | 34 | return new self(sprintf('Twitter API returned the following response:\n\r%s', $responseBody)); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Exception/ClientException.php: -------------------------------------------------------------------------------- 1 | getStatusCode(); 34 | try { 35 | $responseData = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); 36 | } catch (JsonException $exception) { 37 | } 38 | 39 | $errorMessage = sprintf( 40 | self::DEFAULT_ERROR_MESSAGE_FORMAT, 41 | $previousException !== null ? $previousException->getMessage() : '' 42 | ); 43 | $instance = new static( 44 | $errorMessage, 45 | $response->getStatusCode(), 46 | $previousException 47 | ); 48 | $instance->response = $response; 49 | 50 | if (empty($responseData[self::KEY_ERRORS])) { 51 | return $instance; 52 | } 53 | 54 | $error = $responseData[self::KEY_ERRORS][0]; 55 | $errorCode = $error[self::KEY_CODE] ?? $responseStatusCode; 56 | 57 | $instance->message = sprintf(self::MESSAGE_FORMAT, $errorCode, $error[self::KEY_MESSAGE] ?? $errorMessage); 58 | $instance->code = $error[self::KEY_CODE] ?? $response->getStatusCode(); 59 | 60 | return $instance; 61 | } 62 | 63 | public function getResponse(): ?Response 64 | { 65 | return $this->response; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Exception/InvalidConfigException.php: -------------------------------------------------------------------------------- 1 | debug = $debug; 30 | $this->logger = $logger; 31 | } 32 | 33 | final protected function logRequest( 34 | string $method, 35 | string $url, 36 | array $data, 37 | string $logLevel = LogLevel::DEBUG 38 | ): void { 39 | try { 40 | if ($this->logger === null) { 41 | return; 42 | } 43 | 44 | $message = 'Making Request'; 45 | $context = [ 46 | 'method' => $method, 47 | 'query' => $url, 48 | 'url' => $url, 49 | 'params' => http_build_query($data), 50 | ]; 51 | 52 | if (!$this->debug && $logLevel === LogLevel::DEBUG) { 53 | return; 54 | } 55 | 56 | $this->logger->log($logLevel, $message, $context); 57 | } catch (InvalidLogArgumentException $exception) { 58 | return; 59 | } 60 | } 61 | 62 | final protected function deduceClientException(Throwable $exception): TwitterClientException 63 | { 64 | /** @var null|Response $response */ 65 | $response = method_exists($exception, 'getResponse') ? $exception->getResponse() : null; 66 | $this->response = $response; 67 | $responseCode = $response !== null ? $response->getStatusCode() : null; 68 | 69 | switch ($responseCode) { 70 | case 400: 71 | return BadRequestException::fromClientResponse($response, $exception); 72 | case 401: 73 | return UnauthorizedRequestException::fromClientResponse($response, $exception); 74 | case 403: 75 | return ForbiddenRequestException::fromClientResponse($response, $exception); 76 | case 404: 77 | return NotFoundException::fromClientResponse($response, $exception); 78 | case 420: 79 | return RateLimitedException::fromClientResponse($response, $exception); 80 | default: 81 | return new TwitterClientException($exception->getMessage(), $exception->getCode(), $exception); 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Http/Client/AsyncClient.php: -------------------------------------------------------------------------------- 1 | browserCreator = $browserCreator; 42 | $this->oAuth2Provider = $oAuth2Provider; 43 | $this->loop = $loop ?? Factory::create(); 44 | } 45 | 46 | /** 47 | * @throws ClientException 48 | */ 49 | public function request(string $method, string $url, string $body = '', array $parameters = []): PromiseInterface 50 | { 51 | try { 52 | $this->logRequest($method, $url, ['*async' => ['body' => $body, 'params' => $parameters]]); 53 | 54 | $headers = (array) ($parameters[self::KEY_REQUEST_HEADERS] ?? []); 55 | $headers[self::KEY_HEADER_AUTH] = $this->getAuthHeader(); 56 | $timeLimit = (float) ($parameters[self::KEY_STREAM_STOP_AFTER_SECONDS] ?? 0); 57 | $finalUrl = sprintf('%s?%s', $url, $this->getQueryParams($parameters)); 58 | 59 | if ($timeLimit > 0) { 60 | $this->loop 61 | ->addTimer($timeLimit, fn () => $this->loop->stop()); 62 | } 63 | 64 | return $this->getBrowser() 65 | ->request($method, $finalUrl, $headers, $body); 66 | } catch (Throwable $exception) { 67 | throw $this->deduceClientException($exception); 68 | } 69 | } 70 | 71 | /** 72 | * @throws ClientException 73 | * 74 | * @see Browser::requestStreaming() 75 | */ 76 | public function stream(string $method, string $url, array $parameters = []): PromiseInterface 77 | { 78 | try { 79 | $this->logRequest($method, $url, ['*stream' => $parameters]); 80 | 81 | $contents = $parameters[self::KEY_STREAM_CONTENTS] ?? ''; 82 | $timeLimit = (float) ($parameters[self::KEY_STREAM_STOP_AFTER_SECONDS] ?? 0); 83 | $finalUrl = sprintf('%s?%s', $url, $this->getQueryParams($parameters)); 84 | 85 | if ($timeLimit > 0) { 86 | $this->loop 87 | ->addTimer($timeLimit, fn () => $this->loop->stop()); 88 | } 89 | 90 | return $this->getBrowser() 91 | ->requestStreaming($method, $finalUrl, [self::KEY_HEADER_AUTH => $this->getAuthHeader()], $contents); 92 | } catch (Throwable $exception) { 93 | throw $this->deduceClientException($exception); 94 | } 95 | } 96 | 97 | public function loop(): LoopInterface 98 | { 99 | return $this->loop; 100 | } 101 | 102 | /** 103 | * @throws AuthException 104 | */ 105 | protected function getAccessToken(): string 106 | { 107 | try { 108 | if ($this->accessToken !== null && !$this->accessToken->hasExpired()) { 109 | return (string) $this->accessToken; 110 | } 111 | 112 | $this->accessToken = $this->oAuth2Provider->getAccessToken(self::GRANT_TYPE_CLIENT_CREDENTIALS); 113 | 114 | return (string) $this->accessToken; 115 | } catch (IdentityProviderException $exception) { 116 | throw AuthException::fromIdentityProviderException($exception); 117 | } catch (Exception $exception) { 118 | throw new AuthException($exception->getMessage(), $exception->getCode(), $exception); 119 | } 120 | } 121 | 122 | /** 123 | * @throws AuthException 124 | */ 125 | private function getAuthHeader(): string 126 | { 127 | return sprintf('Bearer %s', $this->getAccessToken()); 128 | } 129 | 130 | private function getBrowser(): Browser 131 | { 132 | return $this->browserCreator->create($this->loop); 133 | } 134 | 135 | private function getQueryParams(array $parameters): string 136 | { 137 | $queryParams = $parameters; 138 | 139 | unset( 140 | $queryParams[self::KEY_STREAM_CONTENTS], 141 | $queryParams[self::KEY_STREAM_STOP_AFTER_SECONDS], 142 | $queryParams[self::KEY_REQUEST_HEADERS], 143 | ); 144 | 145 | return http_build_query($queryParams); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/Http/Client/SyncClient.php: -------------------------------------------------------------------------------- 1 | client = $client; 28 | } 29 | 30 | /** 31 | * @return mixed 32 | * 33 | * @throws ClientException 34 | */ 35 | public function request(string $method, string $url, array $data = []) 36 | { 37 | try { 38 | $this->logRequest($method, $url, $data); 39 | 40 | $requestFormat = $data[self::KEY_REQUEST_FORMAT] ?? null; 41 | $responseFormat = $data[self::KEY_RESPONSE_FORMAT] ?? $data[self::KEY_FORMAT] ?? self::RESPONSE_FORMAT_OBJECT; 42 | 43 | unset( 44 | $data[self::KEY_REQUEST_FORMAT], 45 | $data[self::KEY_RESPONSE_FORMAT], 46 | $data[self::KEY_FORMAT] 47 | ); 48 | 49 | $requestOptions = $this->getRequestOptions($method, $data, $requestFormat); 50 | $response = $this->client->request($method, $url, $requestOptions); 51 | $this->response = $response; 52 | 53 | return $this->formatResponse($response, $responseFormat); 54 | } catch (Throwable $exception) { 55 | throw $this->deduceClientException($exception); 56 | } 57 | } 58 | 59 | /** 60 | * @return ResponseInterface|null 61 | */ 62 | public function getLastResponse(): ?ResponseInterface 63 | { 64 | return $this->response; 65 | } 66 | 67 | private function getRequestOptions(string $requestMethod, array $params, ?string $requestFormat): array 68 | { 69 | switch ($requestFormat) { 70 | case self::REQUEST_FORMAT_JSON: 71 | $paramsKey = RequestOptions::JSON; 72 | 73 | break; 74 | case self::REQUEST_FORMAT_MULTIPART: 75 | $paramsKey = RequestOptions::MULTIPART; 76 | 77 | break; 78 | default: 79 | $paramsKey = in_array($requestMethod, [self::REQUEST_METHOD_POST, self::REQUEST_METHOD_PUT], true) 80 | ? RequestOptions::FORM_PARAMS 81 | : RequestOptions::QUERY; 82 | 83 | break; 84 | } 85 | 86 | $options[$paramsKey] = $params; 87 | 88 | return $options; 89 | } 90 | 91 | /** 92 | * @param Response|ResponseInterface $response 93 | * @return mixed 94 | */ 95 | private function formatResponse(ResponseInterface $response, string $format) 96 | { 97 | try { 98 | $body = $response->getBody(); 99 | $content = (string) $body; 100 | 101 | switch ($format) { 102 | case self::RESPONSE_FORMAT_JSON: 103 | return $content; 104 | case self::RESPONSE_FORMAT_ARRAY: 105 | return json_decode($content, true, 512, JSON_THROW_ON_ERROR); 106 | case self::RESPONSE_FORMAT_OBJECT: 107 | default: 108 | return json_decode($content, false, 512, JSON_THROW_ON_ERROR); 109 | } 110 | } catch (RuntimeException $exception) { 111 | if ($this->logger !== null) { 112 | $this->logger->error( 113 | sprintf( 114 | 'A runtime exception occurred when formatting twitter response. %s', 115 | $exception->getMessage() 116 | ) 117 | ); 118 | } 119 | 120 | return null; 121 | } catch (JsonException $exception) { 122 | if ($this->logger !== null) { 123 | $this->logger->error( 124 | sprintf('A JSON exception occurred when formatting twitter response. %s', $exception->getMessage()) 125 | ); 126 | } 127 | 128 | return null; 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/Http/Factory/BrowserCreator.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 27 | } 28 | 29 | /** 30 | * @throws InvalidArgumentException 31 | */ 32 | public function createSyncClient(Configuration $config, bool $useOAuth2 = false): SyncClientContract 33 | { 34 | $builder = $useOAuth2 ? GuzzleClientBuilder::withOAuth2($config) : GuzzleClientBuilder::withOAuth1($config); 35 | 36 | return new SyncClient($builder->build(), $config->isDebugMode(), $this->logger); 37 | } 38 | 39 | public function createAsyncClient(Configuration $config): AsyncClientContract 40 | { 41 | return new AsyncClient( 42 | new BrowserCreator(), 43 | new OAuth2Provider($config), 44 | $config->isDebugMode(), 45 | null, 46 | $this->logger 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Http/Factory/GuzzleClientBuilder.php: -------------------------------------------------------------------------------- 1 | oAuthMiddleware = $oAuthMiddleware; 32 | } 33 | 34 | public static function withOAuth1(Configuration $config): self 35 | { 36 | return new self( 37 | new Oauth1( 38 | [ 39 | 'consumer_key' => $config->getConsumerKey(), 40 | 'consumer_secret' => $config->getConsumerSecret(), 41 | 'token' => $config->getAccessToken(), 42 | 'token_secret' => $config->getAccessTokenSecret(), 43 | ] 44 | ) 45 | ); 46 | } 47 | 48 | /** 49 | * @throws InvalidArgumentException 50 | */ 51 | public static function withOAuth2(Configuration $config): self 52 | { 53 | $middleware = new OAuth2Middleware( 54 | new ClientCredentials( 55 | new Client( 56 | [ 57 | 'base_uri' => self::OAUTH_2_ACCESS_TOKEN_URL, 58 | ] 59 | ), 60 | [ 61 | 'client_id' => $config->getConsumerKey(), 62 | 'client_secret' => $config->getConsumerSecret(), 63 | ] 64 | ) 65 | ); 66 | 67 | return new self($middleware); 68 | } 69 | 70 | /** 71 | * @throws InvalidArgumentException 72 | */ 73 | public function build(): ClientInterface 74 | { 75 | $stack = HandlerStack::create(); 76 | $stack->push($this->oAuthMiddleware); 77 | 78 | return new Client( 79 | [ 80 | 'handler' => $stack, 81 | 'auth' => 'oauth', 82 | ] 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Http/OAuth2Provider.php: -------------------------------------------------------------------------------- 1 | $configuration->getConsumerKey(), 22 | 'clientSecret' => $configuration->getConsumerSecret(), 23 | 'urlAccessToken' => self::ACCESS_TOKEN_URL, 24 | 'redirectUri' => 'http://my.example.com/your-redirect-url/', 25 | 'urlAuthorize' => 'http://service.example.com/authorize', 26 | 'urlResourceOwnerDetails' => 'http://service.example.com/resource', 27 | ] 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Service/Accessor.php: -------------------------------------------------------------------------------- 1 | querier = $querier; 38 | } 39 | 40 | public function getQuerier(): QuerierContract 41 | { 42 | return $this->querier; 43 | } 44 | 45 | private function setQuerier(QuerierContract $querier): self 46 | { 47 | $this->querier = $querier; 48 | 49 | return $this; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Service/Querier.php: -------------------------------------------------------------------------------- 1 | config = $config; 40 | $this->clientFactory = $clientFactory; 41 | $this->syncClient = $clientFactory->createSyncClient($config); 42 | $this->asyncClient = $clientFactory->createAsyncClient($config); 43 | $this->logger = $logger; 44 | } 45 | 46 | /** 47 | * @codeCoverageIgnore 48 | */ 49 | public function getConfiguration(): Configuration 50 | { 51 | return $this->config; 52 | } 53 | 54 | /** 55 | * @codeCoverageIgnore 56 | */ 57 | public function getSyncClient(): SyncClient 58 | { 59 | return $this->syncClient; 60 | } 61 | 62 | /** 63 | * @codeCoverageIgnore 64 | */ 65 | public function getAsyncClient(): AsyncClient 66 | { 67 | return $this->asyncClient; 68 | } 69 | 70 | /** 71 | * @codeCoverageIgnore 72 | * 73 | * @throws InvalidArgumentException 74 | */ 75 | public function usingCredentials( 76 | string $accessToken, 77 | string $accessTokenSecret, 78 | ?string $consumerKey = null, 79 | ?string $consumerSecret = null 80 | ): self { 81 | return new self( 82 | $this->config->withOauthCredentials($accessToken, $accessTokenSecret, $consumerKey, $consumerSecret), 83 | $this->clientFactory 84 | ); 85 | } 86 | 87 | /** 88 | * @codeCoverageIgnore 89 | * 90 | * @throws InvalidArgumentException 91 | */ 92 | public function usingConfiguration(Configuration $configuration): self 93 | { 94 | return new self($configuration, $this->clientFactory); 95 | } 96 | 97 | /** 98 | * @codeCoverageIgnore 99 | * 100 | * @throws InvalidArgumentException 101 | */ 102 | public function withOAuth1Client(): self 103 | { 104 | $instance = clone $this; 105 | $instance->syncClient = $this->clientFactory->createSyncClient($this->config, false); 106 | 107 | return $instance; 108 | } 109 | 110 | /** 111 | * @codeCoverageIgnore 112 | * 113 | * @throws InvalidArgumentException 114 | */ 115 | public function withOAuth2Client(): self 116 | { 117 | $instance = clone $this; 118 | $instance->syncClient = $this->clientFactory->createSyncClient($this->config, true); 119 | 120 | return $instance; 121 | } 122 | 123 | /** 124 | * @throws TwitterClientException 125 | */ 126 | public function directQuery( 127 | string $url, 128 | string $method = self::REQUEST_METHOD_GET, 129 | array $parameters = [] 130 | ) { 131 | return $this->syncClient->request($method, $url, $parameters); 132 | } 133 | 134 | /** 135 | * @throws TwitterClientException 136 | */ 137 | public function query( 138 | string $endpoint, 139 | string $method = self::REQUEST_METHOD_GET, 140 | array $parameters = [], 141 | bool $multipart = false, 142 | ?string $extension = null 143 | ) { 144 | $host = !$multipart ? $this->config->getApiUrl() : $this->config->getUploadUrl(); 145 | $url = $this->buildUrl($endpoint, $host, $extension); 146 | 147 | if ($multipart) { 148 | $parameters[self::KEY_REQUEST_FORMAT] = RequestOptions::MULTIPART; 149 | } 150 | 151 | return $this->syncClient->request($method, $url, $parameters); 152 | } 153 | 154 | /** 155 | * @throws TwitterClientException 156 | */ 157 | public function get(string $endpoint, array $parameters = [], ?string $extension = null) 158 | { 159 | return $this->query($endpoint, self::REQUEST_METHOD_GET, $parameters, false, $extension); 160 | } 161 | 162 | /** 163 | * @throws TwitterClientException 164 | */ 165 | public function post(string $endpoint, array $parameters = [], bool $multipart = false) 166 | { 167 | return $this->query($endpoint, self::REQUEST_METHOD_POST, $parameters, $multipart); 168 | } 169 | 170 | /** 171 | * @throws TwitterClientException 172 | */ 173 | public function put(string $endpoint, array $parameters = []) 174 | { 175 | return $this->query($endpoint, self::REQUEST_METHOD_PUT, $parameters); 176 | } 177 | 178 | /** 179 | * @throws TwitterClientException 180 | */ 181 | public function delete(string $endpoint, array $parameters = []) 182 | { 183 | return $this->query($endpoint, self::REQUEST_METHOD_DELETE, $parameters); 184 | } 185 | 186 | /** 187 | * @throws TwitterClientException 188 | * 189 | * @see https://developer.twitter.com/en/docs/twitter-api/tweets/filtered-stream/api-reference API Reference: Filtered Stream 190 | * @see https://developer.twitter.com/en/docs/twitter-api/tweets/sampled-stream/introduction API Reference: Sampled Stream 191 | */ 192 | public function getStream(string $endpoint, callable $onData, array $parameters = []): void 193 | { 194 | $countLimit = (int) ($parameters[self::KEY_STREAM_STOP_AFTER_COUNT] ?? 0); 195 | $streamed = 0; 196 | 197 | unset($parameters[self::KEY_STREAM_STOP_AFTER_COUNT]); 198 | 199 | $this->asyncClient->stream(self::REQUEST_METHOD_GET, $this->buildUrl($endpoint), $parameters)->then( 200 | function (ResponseInterface $response) use ($onData, $countLimit, $streamed) { 201 | /** @var $stream ReadableStreamInterface */ 202 | $stream = $response->getBody(); 203 | 204 | $stream->on( 205 | AsyncClient::EVENT_DATA, 206 | function (string $chunk) use ($countLimit, $onData, &$streamed) { 207 | $streamed++; 208 | if ($countLimit > 0 && $streamed >= $countLimit) { 209 | $this->asyncClient->loop() 210 | ->stop(); 211 | } 212 | 213 | return ($onData)($chunk); 214 | } 215 | ); 216 | $stream->on( 217 | AsyncClient::EVENT_ERROR, 218 | fn (Throwable $error) => $this->forceLog( 219 | 'Stream [ERROR]: ' . $error->getMessage() . PHP_EOL, 220 | 'error' 221 | ) 222 | 223 | ); 224 | $stream->on( 225 | AsyncClient::EVENT_CLOSE, 226 | fn () => $this->forceLog('Stream [DONE]' . PHP_EOL, 'info') 227 | ); 228 | } 229 | )->otherwise( 230 | function (Exception $exception) { 231 | $this->forceLog('Exception occurred on stream promise: ' . $exception->getMessage() . PHP_EOL, 'error'); 232 | } 233 | ); 234 | 235 | $this->asyncClient->loop() 236 | ->run(); 237 | } 238 | 239 | private function forceLog($message, $logMethod): void 240 | { 241 | if ($this->logger === null) { 242 | echo $message; 243 | 244 | return; 245 | } 246 | 247 | $this->logger->{$logMethod}($message); 248 | } 249 | 250 | private function buildUrl(string $endpoint, ?string $host = null, ?string $extension = null): string 251 | { 252 | return sprintf( 253 | self::URL_FORMAT, 254 | $host ?? $this->config->getApiUrl(), 255 | $this->config->getApiVersion(), 256 | $endpoint, 257 | empty($extension) ? '' : sprintf('.%s', $extension) 258 | ); 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /src/ServiceProvider/LaravelServiceProvider.php: -------------------------------------------------------------------------------- 1 | handleConfig(); 37 | } 38 | 39 | private function handleConfig(): void 40 | { 41 | $app = $this->app ?: app(); 42 | $appVersion = method_exists($app, 'version') ? $app->version() : $app::VERSION; 43 | $laravelVersion = strtolower(substr($appVersion, 0, strpos($appVersion, '.'))); 44 | $configFile = sprintf('%s/config/twitter.php', self::ASSETS_DIR); 45 | $isLumen = stripos($laravelVersion, 'lumen') !== false; 46 | 47 | $this->mergeConfigFrom($configFile, self::CONFIG_KEY); 48 | $this->publishes( 49 | [ 50 | $configFile => $isLumen 51 | ? base_path(sprintf('config/%s.php', self::CONFIG_KEY)) 52 | : config_path(sprintf('%s.php', self::CONFIG_KEY)), 53 | ] 54 | ); 55 | } 56 | 57 | /** 58 | * Register the service provider. 59 | * 60 | * @throws LogicException 61 | */ 62 | public function register(): void 63 | { 64 | $this->app->alias(TwitterContract::class, self::PACKAGE_ALIAS); 65 | $this->app->singleton( 66 | ConfigurationContract::class, 67 | static fn () => Configuration::createFromConfig(config(self::CONFIG_KEY)) 68 | ); 69 | $this->app->singleton(ClientFactory::class, ClientCreator::class); 70 | $this->app->singleton(QuerierContract::class, Querier::class); 71 | $this->app->singleton(TwitterV1Contract::class, TwitterV1::class); 72 | $this->app->singleton(TwitterV2Contract::class, Accessor::class); 73 | $this->app->singleton( 74 | TwitterContract::class, 75 | static function (Application $app) { 76 | $config = $app->get(ConfigurationContract::class); 77 | 78 | if ($config->getApiVersion() !== TwitterContract::API_VERSION_1) { 79 | return $app->get(TwitterV2Contract::class); 80 | } 81 | 82 | return $app->get(TwitterV1Contract::class); 83 | } 84 | ); 85 | } 86 | 87 | /** 88 | * Get the services provided by the provider. 89 | */ 90 | public function provides(): array 91 | { 92 | return [TwitterContract::class]; 93 | } 94 | 95 | /** 96 | * @return mixed 97 | * 98 | * @throws ContainerExceptionInterface 99 | */ 100 | public function resolve(string $name) 101 | { 102 | return $this->app->get($name); 103 | } 104 | 105 | /** 106 | * {@inheritdoc} 107 | */ 108 | public function set(string $name, ...$concrete): void 109 | { 110 | $this->app->bind($name, ...$concrete); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/ServiceProvider/PhpDiServiceProvider.php: -------------------------------------------------------------------------------- 1 | addDefinitions($this->getDefinitions(), ...$additionalDefinitions); 44 | 45 | $this->container = $containerBuilder->build(); 46 | } 47 | 48 | /** 49 | * @noinspection PhpIncludeInspection 50 | */ 51 | public function getDefinitions(): array 52 | { 53 | $config = include sprintf('%s/config/twitter.php', self::ASSETS_DIR); 54 | 55 | return [ 56 | self::PACKAGE_ALIAS => get(TwitterContract::class), 57 | ConfigurationContract::class => static fn (): ConfigurationContract => Configuration::createFromConfig($config), 58 | ClientFactory::class => get(ClientCreator::class), 59 | QuerierContract::class => get(Querier::class), 60 | TwitterV1Contract::class => get(TwitterV1::class), 61 | TwitterV2Contract::class => static function (Container $container): TwitterV2Contract { 62 | $querier = $container->get(QuerierContract::class); 63 | $configuration = $container->get(ConfigurationContract::class) 64 | ->forApiV2(); 65 | 66 | return new Accessor($querier->usingConfiguration($configuration)); 67 | }, 68 | TwitterContract::class => get(TwitterV2Contract::class), 69 | ]; 70 | } 71 | 72 | /** 73 | * {@inheritdoc} 74 | */ 75 | public function set(string $name, ...$concrete): void 76 | { 77 | $this->container->set($name, $concrete[0]); 78 | } 79 | 80 | /** 81 | * @return mixed 82 | * 83 | * @throws ContainerExceptionInterface 84 | */ 85 | public function resolve(string $name) 86 | { 87 | return $this->container->get($name); 88 | } 89 | 90 | public function getContainer(): ?Container 91 | { 92 | return $this->container; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Twitter.php: -------------------------------------------------------------------------------- 1 | initializeDirectory($this->getTempDirectory()); 25 | $this->setUpDatabase(); 26 | } 27 | 28 | protected function getPackageProviders($app): array 29 | { 30 | return [LaravelServiceProvider::class]; 31 | } 32 | 33 | /** 34 | * @param Application $app 35 | */ 36 | protected function getEnvironmentSetUp($app): void 37 | { 38 | $app[self::KEY_CONFIG]->set('mail.driver', 'log'); 39 | 40 | $app[self::KEY_CONFIG]->set('database.default', 'sqlite'); 41 | $app[self::KEY_CONFIG]->set( 42 | 'database.connections.sqlite', 43 | [ 44 | 'driver' => 'sqlite', 45 | 'database' => $this->getTempDirectory() . '/database.sqlite', 46 | 'prefix' => '', 47 | ] 48 | ); 49 | 50 | $app[self::KEY_CONFIG]->set('app.key', '6rE9Nz59bGRbeMATftriyQjrpF7DcOQm'); 51 | } 52 | 53 | /** 54 | * @param string $suffix 55 | */ 56 | private function getTempDirectory($suffix = ''): string 57 | { 58 | return __DIR__ . DIRECTORY_SEPARATOR . 'temp' . ($suffix === '' ? '' : DIRECTORY_SEPARATOR . $suffix); 59 | } 60 | 61 | private function initializeDirectory(string $directory): bool 62 | { 63 | if (File::isDirectory($directory)) { 64 | return File::cleanDirectory($directory); 65 | } 66 | 67 | return File::makeDirectory($directory); 68 | } 69 | 70 | /** 71 | * @throws NoMatchingExpectationException 72 | */ 73 | private function setUpDatabase(): void 74 | { 75 | file_put_contents($this->getTempDirectory() . '/database.sqlite', null); 76 | 77 | $this->artisan('migrate')->run(); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /tests/Integration/Laravel/TwitterTest.php: -------------------------------------------------------------------------------- 1 | serviceProvider = new PhpDiServiceProvider(); 33 | $this->serviceProvider->initContainer(); 34 | } 35 | 36 | public function testTwitterResolution(): void 37 | { 38 | $instance = $this->serviceProvider->resolve(Twitter::class); 39 | 40 | self::assertInstanceOf(Twitter::class, $instance); 41 | } 42 | 43 | public function testTwitterResolutionViaAlias(): void 44 | { 45 | $instance = $this->serviceProvider->resolve(ServiceProvider::PACKAGE_ALIAS); 46 | 47 | self::assertInstanceOf(Twitter::class, $instance); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/Unit/AccessorTestCase.php: -------------------------------------------------------------------------------- 1 | 'bar', 'response_format' => 'json']; 21 | protected const ARBITRARY_RESPONSE = ['response']; 22 | 23 | /** 24 | * @var ObjectProphecy|Configuration 25 | */ 26 | protected ObjectProphecy $config; 27 | 28 | /** 29 | * @var ObjectProphecy|Querier 30 | */ 31 | protected ObjectProphecy $querier; 32 | 33 | /** 34 | * @throws Exception 35 | */ 36 | protected function setUp(): void 37 | { 38 | $this->config = $this->prophesize(Configuration::class); 39 | $this->querier = $this->prophesize(Querier::class); 40 | 41 | $this->config->getApiVersion() 42 | ->willReturn('1.1'); 43 | $this->config->isDebugMode() 44 | ->willReturn(true); 45 | 46 | $this->querier 47 | ->usingCredentials(Argument::cetera()) 48 | ->willReturn($this->querier); 49 | $this->querier 50 | ->usingConfiguration(Argument::cetera()) 51 | ->willReturn($this->querier); 52 | $this->querier 53 | ->withOAuth1Client(Argument::cetera()) 54 | ->willReturn($this->querier); 55 | $this->querier 56 | ->withOAuth2Client(Argument::cetera()) 57 | ->willReturn($this->querier); 58 | $this->querier 59 | ->getConfiguration() 60 | ->willReturn($this->config->reveal()); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/Unit/ApiV1/Traits/ConcernTestCase.php: -------------------------------------------------------------------------------- 1 | subject = $this->getMockForTrait($this->getTraitName()); 23 | } 24 | 25 | abstract protected function getTraitName(): string; 26 | } 27 | -------------------------------------------------------------------------------- /tests/Unit/ApiV1/Traits/FormattingHelpersTest.php: -------------------------------------------------------------------------------- 1 | assertSame( 20 | 'https://twitter.com/atymic', 21 | $this->subject->linkUser($user) 22 | ); 23 | } 24 | 25 | public static function dataGetUserLink(): array 26 | { 27 | return [ 28 | 'string' => ['atymic'], 29 | 'object' => [(object) ['screen_name' => 'atymic']], 30 | 'array' => [['screen_name' => 'atymic']], 31 | ]; 32 | } 33 | 34 | /** 35 | * @dataProvider dataLinkAddTweetToFavorites 36 | */ 37 | public function testLinkAddTweetToFavorites($tweet): void 38 | { 39 | $this->assertSame( 40 | 'https://twitter.com/intent/favorite?tweet_id=1381031025053155332', 41 | $this->subject->linkAddTweetToFavorites($tweet) 42 | ); 43 | } 44 | 45 | public static function dataLinkAddTweetToFavorites(): array 46 | { 47 | return [ 48 | 'object' => [(object) ['id_str' => '1381031025053155332']], 49 | 'array' => [['id_str' => '1381031025053155332']], 50 | ]; 51 | } 52 | 53 | protected function getTraitName(): string 54 | { 55 | return FormattingHelpers::class; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tests/Unit/Concern/ConcernTestCase.php: -------------------------------------------------------------------------------- 1 | subject = $this->getMockForTrait($this->getTraitName()); 23 | 24 | $this->subject->method('getQuerier') 25 | ->willReturn($this->querier->reveal()); 26 | } 27 | 28 | abstract protected function getTraitName(): string; 29 | } 30 | -------------------------------------------------------------------------------- /tests/Unit/Concern/FilteredStreamTest.php: -------------------------------------------------------------------------------- 1 | true; 27 | $params = ['foo' => 'bar']; 28 | 29 | $this->querier->getStream('tweets/search/stream', $onTweet, $params) 30 | ->shouldBeCalledTimes(1); 31 | 32 | $this->subject->getStream($onTweet, $params); 33 | } 34 | 35 | /** 36 | * @throws Exception 37 | */ 38 | public function testGetStreamRules(): void 39 | { 40 | $params = self::ARBITRARY_PARAMS; 41 | $rules = [':foo' => 'bar rule']; 42 | 43 | $this->querier->get('tweets/search/stream/rules', $params) 44 | ->shouldBeCalledTimes(1) 45 | ->willReturn($rules); 46 | 47 | $result = $this->subject->getStreamRules($params); 48 | 49 | self::assertSame($rules, $result); 50 | } 51 | 52 | /** 53 | * @throws Exception 54 | */ 55 | public function testPostStreamRules(): void 56 | { 57 | $params = self::ARBITRARY_PARAMS; 58 | $response = ['response']; 59 | 60 | $this->querier->post( 61 | 'tweets/search/stream/rules', 62 | Argument::that(fn (array $argument): bool => $argument['foo'] === 'bar') 63 | ) 64 | ->shouldBeCalledTimes(1) 65 | ->willReturn($response); 66 | 67 | $result = $this->subject->postStreamRules($params); 68 | 69 | self::assertSame($response, $result); 70 | } 71 | 72 | protected function getTraitName(): string 73 | { 74 | return FilteredStream::class; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/Unit/Concern/FollowsTest.php: -------------------------------------------------------------------------------- 1 | querier->get(sprintf('users/%s/following', $userId), $params) 26 | ->shouldBeCalledTimes(1) 27 | ->willReturn(self::ARBITRARY_RESPONSE); 28 | 29 | $result = $this->subject->getFollowing($userId, $params); 30 | 31 | self::assertSame($result, self::ARBITRARY_RESPONSE); 32 | } 33 | 34 | /** 35 | * @throws Exception 36 | */ 37 | public function testGetFollowers(): void 38 | { 39 | $userId = self::USER_ID; 40 | $params = self::ARBITRARY_PARAMS; 41 | 42 | $this->querier->get(sprintf('users/%s/followers', $userId), $params) 43 | ->shouldBeCalledTimes(1) 44 | ->willReturn(self::ARBITRARY_RESPONSE); 45 | 46 | $result = $this->subject->getFollowers($userId, $params); 47 | 48 | self::assertSame($result, self::ARBITRARY_RESPONSE); 49 | } 50 | 51 | /** 52 | * @throws Exception 53 | */ 54 | public function testFollow(): void 55 | { 56 | $userId = self::USER_ID; 57 | $targetUserId = '199999991'; 58 | 59 | $this->querier->post( 60 | sprintf('users/%s/following', $userId), 61 | Argument::that( 62 | fn (array $argument) => $argument['target_user_id'] === $targetUserId 63 | && $argument[Twitter::KEY_REQUEST_FORMAT] === Twitter::REQUEST_FORMAT_JSON 64 | ) 65 | ) 66 | ->shouldBeCalledTimes(1) 67 | ->willReturn(self::ARBITRARY_RESPONSE); 68 | 69 | $result = $this->subject->follow($userId, $targetUserId); 70 | 71 | self::assertSame($result, self::ARBITRARY_RESPONSE); 72 | } 73 | 74 | /** 75 | * @throws Exception 76 | */ 77 | public function testUnfollow(): void 78 | { 79 | $userId = self::USER_ID; 80 | $targetUserId = '199999991'; 81 | 82 | $this->querier->delete(sprintf('users/%s/following/%s', $userId, $targetUserId), ['response_format' => 'json']) 83 | ->shouldBeCalledTimes(1) 84 | ->willReturn(self::ARBITRARY_RESPONSE); 85 | 86 | $result = $this->subject->unfollow($userId, $targetUserId); 87 | 88 | self::assertSame($result, self::ARBITRARY_RESPONSE); 89 | } 90 | 91 | protected function getTraitName(): string 92 | { 93 | return Follows::class; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /tests/Unit/Concern/HideRepliesTest.php: -------------------------------------------------------------------------------- 1 | querier->put( 26 | sprintf('tweets/%s/hidden', $tweetId), 27 | Argument::that( 28 | fn (array $argument) => $argument['hidden'] === $hidden 29 | && $argument[Twitter::KEY_REQUEST_FORMAT] === Twitter::REQUEST_FORMAT_JSON 30 | ) 31 | ) 32 | ->shouldBeCalledTimes(1) 33 | ->willReturn(self::ARBITRARY_RESPONSE); 34 | 35 | $result = $this->subject->hideTweet($tweetId, $hidden); 36 | 37 | self::assertSame($result, self::ARBITRARY_RESPONSE); 38 | } 39 | 40 | protected function getTraitName(): string 41 | { 42 | return HideReplies::class; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Unit/Concern/HotSwapperTest.php: -------------------------------------------------------------------------------- 1 | prophesize(Configuration::class); 26 | $v1Config->getApiVersion() 27 | ->willReturn('1.1'); 28 | 29 | $this->config->forApiV1() 30 | ->willReturn($v1Config->reveal()); 31 | $this->config->forApiV2() 32 | ->shouldNotBeCalled(); 33 | 34 | $this->querier->usingConfiguration($v1Config) 35 | ->shouldBeCalledTimes(1) 36 | ->willReturn($this->querier->reveal()); 37 | 38 | $result = $this->subject->forApiV1(); 39 | 40 | self::assertInstanceOf(TwitterV1Contract::class, $result); 41 | } 42 | 43 | /** 44 | * @throws Exception 45 | */ 46 | public function testForApiV2(): void 47 | { 48 | /** @var Configuration|ObjectProphecy $v2Config */ 49 | $v2Config = $this->prophesize(Configuration::class); 50 | $v2Config->getApiVersion() 51 | ->willReturn('2'); 52 | 53 | $this->config->forApiV1() 54 | ->shouldNotBeCalled(); 55 | $this->config->forApiV2() 56 | ->willReturn($v2Config->reveal()); 57 | 58 | $this->querier->usingConfiguration($v2Config) 59 | ->shouldBeCalledTimes(1) 60 | ->willReturn($this->querier->reveal()); 61 | 62 | $result = $this->subject->forApiV2(); 63 | 64 | self::assertInstanceOf(TwitterV2Contract::class, $result); 65 | } 66 | 67 | protected function getTraitName(): string 68 | { 69 | return HotSwapper::class; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/Unit/Concern/SampledStreamTest.php: -------------------------------------------------------------------------------- 1 | true; 21 | $params = []; 22 | 23 | $this->querier->getStream('tweets/sample/stream', $onTweet, $params) 24 | ->shouldBeCalledTimes(1); 25 | 26 | $this->subject->getSampledStream($onTweet, $params); 27 | } 28 | 29 | protected function getTraitName(): string 30 | { 31 | return SampledStream::class; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Unit/Concern/SearchTweetsTest.php: -------------------------------------------------------------------------------- 1 | querier->get( 25 | 'tweets/search/recent', 26 | Argument::that( 27 | fn (array $argument) => $argument['query'] === $query 28 | ) 29 | ) 30 | ->shouldBeCalledTimes(1); 31 | 32 | $this->subject->searchRecent($query, $params); 33 | } 34 | 35 | /** 36 | * @throws Exception 37 | */ 38 | public function testSearchAll(): void 39 | { 40 | $query = 'cars'; 41 | $params = self::ARBITRARY_PARAMS; 42 | 43 | $this->querier->get( 44 | 'tweets/search/all', 45 | Argument::that( 46 | fn (array $argument) => $argument['query'] === $query 47 | ) 48 | ) 49 | ->shouldBeCalledTimes(1); 50 | 51 | $this->subject->searchAll($query, $params); 52 | } 53 | 54 | protected function getTraitName(): string 55 | { 56 | return SearchTweets::class; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/Unit/Concern/TimelinesTest.php: -------------------------------------------------------------------------------- 1 | querier->get(sprintf('users/%s/tweets', $userId), $params) 24 | ->shouldBeCalledTimes(1); 25 | 26 | $this->subject->userTweets($userId, $params); 27 | } 28 | 29 | /** 30 | * @throws Exception 31 | */ 32 | public function testUserMentions(): void 33 | { 34 | $userId = self::USER_ID; 35 | $params = self::ARBITRARY_PARAMS; 36 | 37 | $this->querier->get(sprintf('users/%s/mentions', $userId), $params) 38 | ->shouldBeCalledTimes(1); 39 | 40 | $this->subject->userMentions($userId, $params); 41 | } 42 | 43 | protected function getTraitName(): string 44 | { 45 | return Timelines::class; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/Unit/Concern/TweetCountsTest.php: -------------------------------------------------------------------------------- 1 | querier->get( 24 | 'tweets/counts/recent', 25 | Argument::that( 26 | fn (array $argument) => $argument['query'] === $query 27 | ) 28 | )->shouldBeCalledTimes(1); 29 | 30 | $this->subject->countRecent($query); 31 | } 32 | 33 | /** 34 | * @throws Exception 35 | */ 36 | public function testCountAll(): void 37 | { 38 | $query = 'foobar'; 39 | 40 | $this->querier->get( 41 | 'tweets/counts/all', 42 | Argument::that( 43 | fn (array $argument) => $argument['query'] === $query 44 | ) 45 | )->shouldBeCalledTimes(1); 46 | 47 | $this->subject->countAll($query); 48 | } 49 | 50 | protected function getTraitName(): string 51 | { 52 | return TweetCounts::class; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/Unit/Concern/TweetLookupTest.php: -------------------------------------------------------------------------------- 1 | querier->get(sprintf('tweets/%s', $tweetId), $params) 25 | ->shouldBeCalledTimes(1); 26 | 27 | $this->subject->getTweet($tweetId, $params); 28 | } 29 | 30 | /** 31 | * @throws Exception 32 | */ 33 | public function testGetTweets(): void 34 | { 35 | $tweetId1 = '987654321'; 36 | $tweetId2 = '123456789'; 37 | $params = self::ARBITRARY_PARAMS; 38 | 39 | $this->querier->get( 40 | 'tweets', 41 | Argument::that( 42 | fn (array $argument) => strpos($argument['ids'], $tweetId1) !== false 43 | && strpos($argument['ids'], $tweetId2) !== false 44 | ) 45 | ) 46 | ->shouldBeCalledTimes(1); 47 | 48 | $this->subject->getTweets([$tweetId1, $tweetId2], $params); 49 | } 50 | 51 | protected function getTraitName(): string 52 | { 53 | return TweetLookup::class; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/Unit/Concern/UserLookupTest.php: -------------------------------------------------------------------------------- 1 | querier->get(sprintf('users/%s', $userId), $params) 25 | ->shouldBeCalledTimes(1) 26 | ->willReturn(self::ARBITRARY_RESPONSE); 27 | 28 | $this->subject->getUser($userId, $params); 29 | } 30 | 31 | /** 32 | * @throws Exception 33 | */ 34 | public function testGetUsers(): void 35 | { 36 | $userId1 = self::USER_ID; 37 | $userId2 = '32452123'; 38 | $userIds = [$userId1, $userId2]; 39 | $params = self::ARBITRARY_PARAMS; 40 | 41 | $this->querier->get( 42 | 'users', 43 | Argument::that( 44 | fn (array $argument) => strpos($argument['ids'], $userId1) !== false 45 | && strpos($argument['ids'], $userId2) !== false 46 | ) 47 | ) 48 | ->shouldBeCalledTimes(1) 49 | ->willReturn(self::ARBITRARY_RESPONSE); 50 | 51 | $this->subject->getUsers($userIds, $params); 52 | } 53 | 54 | /** 55 | * @throws Exception 56 | */ 57 | public function testGetUserByUsername(): void 58 | { 59 | $username = 'user'; 60 | $params = self::ARBITRARY_PARAMS; 61 | 62 | $this->querier->get(sprintf('users/by/username/%s', $username), $params) 63 | ->shouldBeCalledTimes(1) 64 | ->willReturn(self::ARBITRARY_RESPONSE); 65 | 66 | $this->subject->getUserByUsername($username, $params); 67 | } 68 | 69 | /** 70 | * @throws Exception 71 | */ 72 | public function testGetUsersByUsernames(): void 73 | { 74 | $username1 = 'user1'; 75 | $username2 = 'user2'; 76 | $params = self::ARBITRARY_PARAMS; 77 | 78 | $this->querier->get( 79 | 'users/by', 80 | Argument::that( 81 | fn (array $argument) => strpos($argument['usernames'], $username1) !== false 82 | && strpos($argument['usernames'], $username2) !== false 83 | ) 84 | ) 85 | ->shouldBeCalledTimes(1) 86 | ->willReturn(self::ARBITRARY_RESPONSE); 87 | 88 | $this->subject->getUsersByUsernames([$username1, $username2], $params); 89 | } 90 | 91 | protected function getTraitName(): string 92 | { 93 | return UserLookup::class; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /tests/Unit/Http/Client/AsyncClientTest.php: -------------------------------------------------------------------------------- 1 | browserCreator = $this->prophesize(BrowserCreator::class); 62 | $this->oAuth2Provider = $this->prophesize(OAuth2Provider::class); 63 | $this->loop = $this->prophesize(LoopInterface::class); 64 | $this->logger = $this->prophesize(LoggerInterface::class); 65 | $this->promise = $this->prophesize(PromiseInterface::class) 66 | ->reveal(); 67 | $this->subject = new AsyncClient( 68 | $this->browserCreator->reveal(), 69 | $this->oAuth2Provider->reveal(), 70 | true, 71 | $this->loop->reveal(), 72 | $this->logger->reveal() 73 | ); 74 | 75 | /** 76 | * @var ObjectProphecy|AccessTokenInterface $accessToken 77 | * @var ObjectProphecy|Browser $browser 78 | */ 79 | $accessToken = $this->prophesize(AccessTokenInterface::class); 80 | $browser = $this->prophesize(Browser::class); 81 | 82 | $accessToken->__toString() 83 | ->willReturn('ACCESS_TOKEN'); 84 | 85 | $browser->request(Argument::cetera()) 86 | ->willReturn($this->promise); 87 | $browser->requestStreaming(Argument::cetera()) 88 | ->willReturn($this->promise); 89 | 90 | $this->browserCreator 91 | ->create(Argument::type(LoopInterface::class)) 92 | ->willReturn($browser); 93 | 94 | $this->oAuth2Provider 95 | ->getAccessToken(Argument::cetera()) 96 | ->willReturn($accessToken->reveal()); 97 | } 98 | 99 | /** 100 | * @covers ::__construct 101 | * @covers ::request 102 | * @covers ::getAccessToken 103 | * @covers ::getAuthHeader 104 | * @covers ::getBrowser 105 | * @covers ::getQueryParams 106 | * @covers \Atymic\Twitter\Http\Client::__construct 107 | * @covers \Atymic\Twitter\Http\Client::logRequest 108 | * 109 | * @throws Exception 110 | */ 111 | public function testRequest(): void 112 | { 113 | self::assertSame( 114 | $this->promise, 115 | $this->subject->request( 116 | 'GET', 117 | '//url', 118 | '', 119 | [ 120 | Twitter::KEY_STREAM_STOP_AFTER_SECONDS => 3, 121 | ] 122 | ) 123 | ); 124 | } 125 | 126 | /** 127 | * @covers ::__construct 128 | * @covers ::request 129 | * @covers ::getAccessToken 130 | * @covers ::getAuthHeader 131 | * @covers \Atymic\Twitter\Http\Client::__construct 132 | * @covers \Atymic\Twitter\Http\Client::logRequest 133 | * @covers \Atymic\Twitter\Http\Client::deduceClientException 134 | * 135 | * @throws Exception 136 | */ 137 | public function testRequestWhenExceptionOccurs(): void 138 | { 139 | $this->oAuth2Provider 140 | ->getAccessToken(Argument::cetera()) 141 | ->willThrow(new Exception('foo')); 142 | 143 | $this->expectException(TwitterException::class); 144 | 145 | $this->subject->request('POST', '//url'); 146 | } 147 | 148 | /** 149 | * @covers ::__construct 150 | * @covers ::stream 151 | * @covers ::getAccessToken 152 | * @covers ::getAuthHeader 153 | * @covers ::getBrowser 154 | * @covers ::getQueryParams 155 | * @covers \Atymic\Twitter\Http\Client::__construct 156 | * @covers \Atymic\Twitter\Http\Client::logRequest 157 | * 158 | * @throws Exception 159 | */ 160 | public function testStream(): void 161 | { 162 | self::assertSame( 163 | $this->promise, 164 | $this->subject->stream( 165 | 'GET', 166 | '//url', 167 | [ 168 | Twitter::KEY_STREAM_STOP_AFTER_SECONDS => 3, 169 | ] 170 | ) 171 | ); 172 | } 173 | 174 | /** 175 | * @covers ::__construct 176 | * @covers ::stream 177 | * @covers ::getAccessToken 178 | * @covers ::getAuthHeader 179 | * @covers \Atymic\Twitter\Http\Client::__construct 180 | * @covers \Atymic\Twitter\Http\Client::logRequest 181 | * @covers \Atymic\Twitter\Http\Client::deduceClientException 182 | * 183 | * @throws Exception 184 | */ 185 | public function testStreamWhenExceptionOccurs(): void 186 | { 187 | $this->oAuth2Provider 188 | ->getAccessToken(Argument::cetera()) 189 | ->willThrow(new IdentityProviderException('foo', 400, '')); 190 | 191 | $this->expectException(TwitterException::class); 192 | 193 | $this->subject->stream('POST', '//url'); 194 | } 195 | 196 | /** 197 | * @covers ::loop 198 | */ 199 | public function testLoop() 200 | { 201 | self::assertSame($this->loop->reveal(), $this->subject->loop()); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /tests/Unit/Http/Client/SyncClientTest.php: -------------------------------------------------------------------------------- 1 | client = $this->prophesize(ClientInterface::class); 55 | $this->response = $this->prophesize(ResponseInterface::class); 56 | $this->logger = $this->prophesize(LoggerInterface::class); 57 | $this->subject = new SyncClient($this->client->reveal(), false, $this->logger->reveal()); 58 | 59 | $this->client->request(Argument::cetera()) 60 | ->willReturn($this->response->reveal()); 61 | 62 | $this->response->getBody() 63 | ->willReturn('{"foo": "bar"}'); 64 | } 65 | 66 | /** 67 | * @covers ::__construct 68 | * @covers ::request 69 | * @covers ::getRequestOptions 70 | * @covers ::formatResponse 71 | * 72 | * @throws Throwable 73 | */ 74 | public function testRequest(): void 75 | { 76 | $method = 'POST'; 77 | $url = '//url'; 78 | $data = []; 79 | 80 | $result = $this->subject->request($method, $url, $data); 81 | 82 | self::assertInstanceOf(stdClass::class, $result); 83 | self::assertSame('bar', $result->foo); 84 | self::assertInstanceOf(ResponseInterface::class, $this->subject->getLastResponse()); 85 | } 86 | 87 | /** 88 | * @covers ::__construct 89 | * @covers ::request 90 | * @covers ::getRequestOptions 91 | * @covers ::formatResponse 92 | * 93 | * @throws Throwable 94 | */ 95 | public function testJsonRequestJsonResponse(): void 96 | { 97 | $method = 'GET'; 98 | $url = '//url'; 99 | $data = [ 100 | Twitter::KEY_REQUEST_FORMAT => Twitter::REQUEST_FORMAT_JSON, 101 | Twitter::KEY_RESPONSE_FORMAT => Twitter::RESPONSE_FORMAT_JSON, 102 | 'key' => 'value', 103 | ]; 104 | 105 | $this->client->request( 106 | $method, 107 | $url, 108 | Argument::that( 109 | fn (array $argument) => $argument[RequestOptions::JSON] === ['key' => 'value'] 110 | ) 111 | ) 112 | ->shouldBeCalledTimes(1) 113 | ->willReturn($this->response->reveal()); 114 | 115 | $result = $this->subject->request($method, $url, $data); 116 | 117 | self::assertJson($result); 118 | self::assertStringContainsString('foo', $result); 119 | self::assertStringContainsString('bar', $result); 120 | } 121 | 122 | /** 123 | * @covers ::__construct 124 | * @covers ::request 125 | * @covers ::getRequestOptions 126 | * @covers ::formatResponse 127 | * 128 | * @throws Throwable 129 | */ 130 | public function testMultipartRequestArrayResponse(): void 131 | { 132 | $method = 'POST'; 133 | $url = '//url'; 134 | $data = [ 135 | Twitter::KEY_REQUEST_FORMAT => Twitter::REQUEST_FORMAT_MULTIPART, 136 | Twitter::KEY_RESPONSE_FORMAT => Twitter::RESPONSE_FORMAT_ARRAY, 137 | 'field' => 'value', 138 | ]; 139 | 140 | $this->client->request( 141 | $method, 142 | $url, 143 | Argument::that( 144 | fn (array $argument) => $argument[RequestOptions::MULTIPART] === ['field' => 'value'] 145 | ) 146 | ) 147 | ->shouldBeCalledTimes(1) 148 | ->willReturn($this->response->reveal()); 149 | 150 | $result = $this->subject->request($method, $url, $data); 151 | 152 | self::assertIsArray($result); 153 | self::assertSame('bar', $result['foo']); 154 | } 155 | 156 | /** 157 | * @covers ::__construct 158 | * @covers ::request 159 | * @covers ::getRequestOptions 160 | * @covers ::formatResponse 161 | * 162 | * @throws Throwable 163 | */ 164 | public function testRequestWhenRuntimeExceptionOccursInResponseFormatting(): void 165 | { 166 | $this->response->getBody() 167 | ->shouldBeCalledTimes(1) 168 | ->willThrow(new RuntimeException()); 169 | 170 | $this->logger->error(Argument::cetera()) 171 | ->shouldBeCalledTimes(1); 172 | 173 | $result = $this->subject->request('GET', '//url', []); 174 | 175 | self::assertNull($result); 176 | } 177 | 178 | /** 179 | * @covers ::__construct 180 | * @covers ::request 181 | * @covers ::getRequestOptions 182 | * @covers ::formatResponse 183 | * 184 | * @throws Throwable 185 | */ 186 | public function testRequestWhenJsonExceptionOccursInResponseFormatting(): void 187 | { 188 | $this->response->getBody() 189 | ->shouldBeCalledTimes(1) 190 | ->willThrow(new JsonException()); 191 | 192 | $this->logger->error(Argument::cetera()) 193 | ->shouldBeCalledTimes(1); 194 | 195 | $result = $this->subject->request('GET', '//url', []); 196 | 197 | self::assertNull($result); 198 | } 199 | 200 | /** 201 | * @covers ::__construct 202 | * @covers ::request 203 | * @covers ::getRequestOptions 204 | * @covers ::formatResponse 205 | * 206 | * @throws Throwable 207 | */ 208 | public function testRequestWhenExceptionOccurs(): void 209 | { 210 | $this->response->getBody() 211 | ->shouldBeCalledTimes(1) 212 | ->willThrow(new Exception()); 213 | 214 | $this->expectException(ClientException::class); 215 | 216 | $this->subject->request('GET', '//url', []); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /tests/Unit/Service/AccessorTest.php: -------------------------------------------------------------------------------- 1 | subject = new Accessor($this->querier->reveal()); 33 | } 34 | 35 | /** 36 | * @covers \Atymic\Twitter\Concern\HotSwapper::usingCredentials 37 | * @covers ::__construct 38 | * @covers ::getQuerier 39 | * 40 | * @throws Exception 41 | */ 42 | public function testUsingCredentials(): void 43 | { 44 | $accessToken = 'token'; 45 | $accessTokenSecret = 'secret'; 46 | $consumerKey = 'consumer-key'; 47 | $consumerSecret = 'consumer-secret'; 48 | 49 | $this->config->getAccessToken() 50 | ->willReturn($accessToken); 51 | $this->config->getAccessTokenSecret() 52 | ->willReturn($accessTokenSecret); 53 | $this->config->getConsumerKey() 54 | ->willReturn($consumerKey); 55 | $this->config->getConsumerSecret() 56 | ->willReturn($consumerSecret); 57 | 58 | $result = $this->subject 59 | ->usingCredentials($accessToken, $accessTokenSecret, $consumerKey, $consumerSecret); 60 | $resultConfig = $result->getQuerier() 61 | ->getConfiguration(); 62 | 63 | self::assertInstanceOf(Twitter::class, $result); 64 | self::assertSame($result, $this->subject); 65 | self::assertSame($accessToken, $resultConfig->getAccessToken()); 66 | self::assertSame($accessTokenSecret, $resultConfig->getAccessTokenSecret()); 67 | self::assertSame($consumerKey, $resultConfig->getConsumerKey()); 68 | self::assertSame($consumerSecret, $resultConfig->getConsumerSecret()); 69 | } 70 | 71 | /** 72 | * @covers \Atymic\Twitter\Concern\HotSwapper::usingConfiguration 73 | * @covers ::__construct 74 | * @covers ::getQuerier 75 | * 76 | * @throws Exception 77 | */ 78 | public function testUsingConfiguration(): void 79 | { 80 | $accessToken = 'access-token'; 81 | 82 | $this->config->getAccessToken() 83 | ->willReturn($accessToken); 84 | 85 | $result = $this->subject 86 | ->usingConfiguration($this->config->reveal()); 87 | $resultConfig = $result->getQuerier() 88 | ->getConfiguration(); 89 | 90 | self::assertInstanceOf(Twitter::class, $result); 91 | self::assertSame($result, $this->subject); 92 | self::assertSame($resultConfig->getAccessToken(), $accessToken); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tests/Unit/Service/QuerierTest.php: -------------------------------------------------------------------------------- 1 | config = $this->prophesize(Configuration::class); 69 | $this->clientFactory = $this->prophesize(ClientFactory::class); 70 | $this->syncClient = $this->prophesize(SyncClient::class); 71 | $this->asyncClient = $this->prophesize(AsyncClient::class); 72 | $this->logger = $this->prophesize(LoggerInterface::class); 73 | 74 | $this->config->getApiUrl() 75 | ->willReturn(self::API_URL); 76 | $this->config->getApiVersion() 77 | ->willReturn(self::API_VERSION); 78 | $this->config->getUploadUrl() 79 | ->willReturn(self::UPLOAD_URL); 80 | 81 | $this->clientFactory->createSyncClient($this->config->reveal()) 82 | ->willReturn($this->syncClient->reveal()); 83 | $this->clientFactory->createAsyncClient($this->config->reveal()) 84 | ->willReturn($this->asyncClient->reveal()); 85 | 86 | $this->subject = new Querier($this->config->reveal(), $this->clientFactory->reveal()); 87 | } 88 | 89 | /** 90 | * @covers ::__construct 91 | * @covers ::directQuery 92 | * 93 | * @throws Exception 94 | */ 95 | public function testDirectQuery(): void 96 | { 97 | $url = '//url'; 98 | $method = '//url'; 99 | $params = []; 100 | $response = '{}'; 101 | 102 | $this->syncClient->request($method, $url, $params) 103 | ->shouldBeCalledTimes(1) 104 | ->willReturn($response); 105 | 106 | $result = $this->subject->directQuery($url, $method, $params); 107 | 108 | self::assertSame($response, $result); 109 | } 110 | 111 | /** 112 | * @covers ::__construct 113 | * @covers ::query 114 | * @covers ::buildUrl 115 | * 116 | * @throws Exception 117 | */ 118 | public function testQuery(): void 119 | { 120 | $endpoint = 'endpoint'; 121 | $method = 'GET'; 122 | $params = []; 123 | $response = '{}'; 124 | $multipart = true; 125 | $extension = 'ext'; 126 | 127 | $this->syncClient->request( 128 | $method, 129 | Argument::that( 130 | fn (string $argument) => strpos( 131 | $argument, 132 | sprintf( 133 | '%s/%s/%s.%s', 134 | self::UPLOAD_URL, 135 | self::API_VERSION, 136 | $endpoint, 137 | $extension 138 | ) 139 | ) !== false 140 | ), 141 | Argument::that(fn (array $argument) => $argument[Twitter::KEY_REQUEST_FORMAT] === RequestOptions::MULTIPART) 142 | ) 143 | ->shouldBeCalledTimes(1) 144 | ->willReturn($response); 145 | 146 | $result = $this->subject->query($endpoint, $method, $params, $multipart, $extension); 147 | 148 | self::assertSame($response, $result); 149 | } 150 | 151 | /** 152 | * @covers ::__construct 153 | * @covers ::get 154 | * @covers ::query 155 | * @covers ::buildUrl 156 | * 157 | * @throws Exception 158 | */ 159 | public function testGet(): void 160 | { 161 | $endpoint = 'endpoint'; 162 | $params = []; 163 | $response = '{}'; 164 | $extension = 'ext'; 165 | 166 | $this->syncClient->request( 167 | 'GET', 168 | Argument::that( 169 | fn (string $argument) => strpos( 170 | $argument, 171 | sprintf( 172 | '%s/%s/%s.%s', 173 | self::API_URL, 174 | self::API_VERSION, 175 | $endpoint, 176 | $extension 177 | ) 178 | ) !== false 179 | ), 180 | $params 181 | ) 182 | ->shouldBeCalledTimes(1) 183 | ->willReturn($response); 184 | 185 | $result = $this->subject->get($endpoint, $params, $extension); 186 | 187 | self::assertSame($response, $result); 188 | } 189 | 190 | /** 191 | * @covers ::__construct 192 | * @covers ::post 193 | * @covers ::query 194 | * @covers ::buildUrl 195 | * 196 | * @throws Exception 197 | */ 198 | public function testPost(): void 199 | { 200 | $endpoint = 'endpoint'; 201 | $params = []; 202 | $response = '{}'; 203 | 204 | $this->syncClient->request( 205 | 'POST', 206 | Argument::that( 207 | fn (string $argument) => strpos( 208 | $argument, 209 | sprintf( 210 | '%s/%s/%s', 211 | self::API_URL, 212 | self::API_VERSION, 213 | $endpoint 214 | ) 215 | ) !== false 216 | ), 217 | $params 218 | ) 219 | ->shouldBeCalledTimes(1) 220 | ->willReturn($response); 221 | 222 | $result = $this->subject->post($endpoint, $params); 223 | 224 | self::assertSame($response, $result); 225 | } 226 | 227 | /** 228 | * @covers ::__construct 229 | * @covers ::put 230 | * @covers ::query 231 | * @covers ::buildUrl 232 | * 233 | * @throws Exception 234 | */ 235 | public function testPut(): void 236 | { 237 | $endpoint = 'endpoint'; 238 | $params = []; 239 | $response = '{}'; 240 | 241 | $this->syncClient->request( 242 | 'PUT', 243 | Argument::that( 244 | fn (string $argument) => strpos( 245 | $argument, 246 | sprintf( 247 | '%s/%s/%s', 248 | self::API_URL, 249 | self::API_VERSION, 250 | $endpoint 251 | ) 252 | ) !== false 253 | ), 254 | $params 255 | ) 256 | ->shouldBeCalledTimes(1) 257 | ->willReturn($response); 258 | 259 | $result = $this->subject->put($endpoint, $params); 260 | 261 | self::assertSame($response, $result); 262 | } 263 | 264 | /** 265 | * @covers ::__construct 266 | * @covers ::delete 267 | * @covers ::query 268 | * @covers ::buildUrl 269 | * 270 | * @throws Exception 271 | */ 272 | public function testDelete(): void 273 | { 274 | $endpoint = 'endpoint'; 275 | $params = []; 276 | $response = '{}'; 277 | 278 | $this->syncClient->request( 279 | 'DELETE', 280 | Argument::that( 281 | fn (string $argument) => strpos( 282 | $argument, 283 | sprintf( 284 | '%s/%s/%s', 285 | self::API_URL, 286 | self::API_VERSION, 287 | $endpoint 288 | ) 289 | ) !== false 290 | ), 291 | $params 292 | ) 293 | ->shouldBeCalledTimes(1) 294 | ->willReturn($response); 295 | 296 | $result = $this->subject->delete($endpoint, $params); 297 | 298 | self::assertSame($response, $result); 299 | } 300 | } 301 | --------------------------------------------------------------------------------