├── .gitignore ├── .php-cs-fixer.dist.php ├── LICENSE ├── README.md ├── composer.json ├── examples └── file-upload.php ├── phpunit.xml ├── src └── Discord │ ├── Bucket.php │ ├── DriverInterface.php │ ├── Drivers │ ├── Guzzle.php │ └── React.php │ ├── Endpoint.php │ ├── Exceptions │ ├── BadRequestException.php │ ├── ContentTooLongException.php │ ├── InvalidTokenException.php │ ├── MethodNotAllowedException.php │ ├── NoPermissionsException.php │ ├── NotFoundException.php │ ├── RateLimitException.php │ └── RequestFailedException.php │ ├── Http.php │ ├── Multipart │ ├── MultipartBody.php │ └── MultipartField.php │ ├── RateLimit.php │ └── Request.php └── tests ├── Discord ├── DriverInterfaceTest.php ├── EndpointTest.php ├── Multipart │ └── MultipartTest.php └── RequestTest.php └── Drivers ├── GuzzleTest.php ├── ReactTest.php └── _server.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | composer.lock 3 | test.php 4 | .php_cs.cache 5 | .php_cs 6 | .php-cs-fixer.php 7 | .php-cs-fixer.cache 8 | .vscode 9 | .phpunit.cache 10 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | 7 | 8 | This file is subject to the MIT license that is bundled 9 | with this source code in the LICENSE file. 10 | EOF; 11 | 12 | $fixers = [ 13 | 'blank_line_after_namespace', 14 | 'braces', 15 | 'class_definition', 16 | 'elseif', 17 | 'encoding', 18 | 'full_opening_tag', 19 | 'function_declaration', 20 | 'lowercase_keywords', 21 | 'method_argument_space', 22 | 'no_closing_tag', 23 | 'no_spaces_after_function_name', 24 | 'no_spaces_inside_parenthesis', 25 | 'no_trailing_whitespace', 26 | 'no_trailing_whitespace_in_comment', 27 | 'single_blank_line_at_eof', 28 | 'single_class_element_per_statement', 29 | 'single_import_per_statement', 30 | 'single_line_after_imports', 31 | 'switch_case_semicolon_to_colon', 32 | 'switch_case_space', 33 | 'visibility_required', 34 | 'blank_line_after_opening_tag', 35 | 'no_multiline_whitespace_around_double_arrow', 36 | 'no_empty_statement', 37 | 'include', 38 | 'no_trailing_comma_in_list_call', 39 | 'not_operator_with_successor_space', 40 | 'no_leading_namespace_whitespace', 41 | 'no_blank_lines_after_class_opening', 42 | 'no_blank_lines_after_phpdoc', 43 | 'object_operator_without_whitespace', 44 | 'binary_operator_spaces', 45 | 'phpdoc_indent', 46 | 'general_phpdoc_tag_rename', 47 | 'phpdoc_inline_tag_normalizer', 48 | 'phpdoc_tag_type', 49 | 'phpdoc_no_access', 50 | 'phpdoc_no_package', 51 | 'phpdoc_scalar', 52 | 'phpdoc_summary', 53 | 'phpdoc_to_comment', 54 | 'phpdoc_trim', 55 | 'phpdoc_var_without_name', 56 | 'no_leading_import_slash', 57 | 'no_trailing_comma_in_singleline_array', 58 | 'single_blank_line_before_namespace', 59 | 'single_quote', 60 | 'no_singleline_whitespace_before_semicolons', 61 | 'cast_spaces', 62 | 'standardize_not_equals', 63 | 'ternary_operator_spaces', 64 | 'trim_array_spaces', 65 | 'unary_operator_spaces', 66 | 'no_unused_imports', 67 | 'no_useless_else', 68 | 'no_useless_return', 69 | 'phpdoc_no_empty_return', 70 | 'no_extra_blank_lines', 71 | 'multiline_whitespace_before_semicolons', 72 | ]; 73 | 74 | $rules = [ 75 | 'concat_space' => ['spacing' => 'none'], 76 | 'phpdoc_no_alias_tag' => ['replacements' => ['type' => 'var']], 77 | 'array_syntax' => ['syntax' => 'short'], 78 | 'binary_operator_spaces' => ['align_double_arrow' => true, 'align_equals' => true], 79 | 'header_comment' => ['header' => $header], 80 | 'indentation_type' => true, 81 | 'phpdoc_align' => [ 82 | 'align' => 'vertical', 83 | 'tags' => ['param', 'property', 'property-read', 'property-write', 'return', 'throws', 'type', 'var', 'method'], 84 | ], 85 | 'blank_line_before_statement' => ['statements' => ['return']], 86 | 'constant_case' => ['case' => 'lower'], 87 | 'echo_tag_syntax' => ['format' => 'long'], 88 | 'trailing_comma_in_multiline' => ['elements' => ['arrays']], 89 | ]; 90 | 91 | foreach ($fixers as $fix) { 92 | $rules[$fix] = true; 93 | } 94 | 95 | $config = new PhpCsFixer\Config(); 96 | 97 | return $config 98 | ->setRules($rules) 99 | ->setFinder( 100 | PhpCsFixer\Finder::create() 101 | ->in(__DIR__) 102 | ); 103 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021-present David Cole and all 4 | contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DiscordPHP-Http 2 | 3 | Asynchronous HTTP client used for communication with the Discord REST API. 4 | 5 | ## Requirements 6 | 7 | - PHP >=7.4 8 | 9 | ## Installation 10 | 11 | ```sh 12 | $ composer require discord-php/http 13 | ``` 14 | 15 | A [psr/log](https://packagist.org/packages/psr/log)-compliant logging library is also required. We recommend [monolog](https://github.com/Seldaek/monolog) which will be used in examples. 16 | 17 | ## Usage 18 | 19 | ```php 20 | pushHandler(new StreamHandler('php://output')); 31 | $http = new Http( 32 | 'Bot xxxx.yyyy.zzzz', 33 | $loop, 34 | $logger 35 | ); 36 | 37 | // set up a driver - this example uses the React driver 38 | $driver = new React($loop); 39 | $http->setDriver($driver); 40 | 41 | // must be the last line 42 | $loop->run(); 43 | ``` 44 | 45 | All request methods have the same footprint: 46 | 47 | ```php 48 | $http->get(string $url, $content = null, array $headers = []); 49 | $http->post(string $url, $content = null, array $headers = []); 50 | $http->put(string $url, $content = null, array $headers = []); 51 | $http->patch(string $url, $content = null, array $headers = []); 52 | $http->delete(string $url, $content = null, array $headers = []); 53 | ``` 54 | 55 | For other methods: 56 | 57 | ```php 58 | $http->queueRequest(string $method, string $url, $content, array $headers = []); 59 | ``` 60 | 61 | All methods return the decoded JSON response in an object: 62 | 63 | ```php 64 | // https://discord.com/api/v8/oauth2/applications/@me 65 | $http->get('oauth2/applications/@me')->done(function ($response) { 66 | var_dump($response); 67 | }, function ($e) { 68 | echo "Error: ".$e->getMessage().PHP_EOL; 69 | }); 70 | ``` 71 | 72 | Most Discord endpoints are provided in the [Endpoint.php](src/Discord/Endpoint.php) class as constants. Parameters start with a colon, 73 | e.g. `channels/:channel_id/messages/:message_id`. You can bind parameters to then with the same class: 74 | 75 | ```php 76 | // channels/channel_id_here/messages/message_id_here 77 | $endpoint = Endpoint::bind(Endpoint::CHANNEL_MESSAGE, 'channel_id_here', 'message_id_here'); 78 | 79 | $http->get($endpoint)->done(...); 80 | ``` 81 | 82 | It is recommended that if the endpoint contains parameters you use the `Endpoint::bind()` function to sort requests into their correct rate limit buckets. 83 | For an example, see [DiscordPHP](https://github.com/discord-php/DiscordPHP). 84 | 85 | ## License 86 | 87 | This software is licensed under the MIT license which can be viewed in the [LICENSE](LICENSE) file. 88 | 89 | ## Credits 90 | 91 | - [David Cole](mailto:david.cole1340@gmail.com) 92 | - All contributors 93 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord-php/http", 3 | "description": "Handles HTTP requests to Discord servers", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "David Cole", 9 | "email": "david.cole1340@gmail.com" 10 | } 11 | ], 12 | "autoload": { 13 | "psr-4": { 14 | "Discord\\Http\\": "src/Discord", 15 | "Tests\\Discord\\Http\\": "tests/Discord" 16 | } 17 | }, 18 | "require": { 19 | "php": "^7.4|^8.0", 20 | "react/http": "^1.2", 21 | "psr/log": "^1.1 || ^2.0 || ^3.0", 22 | "react/promise": "^2.2 || ^3.0.0" 23 | }, 24 | "suggest": { 25 | "guzzlehttp/guzzle": "For alternative to ReactPHP/Http Browser" 26 | }, 27 | "require-dev": { 28 | "monolog/monolog": "^2.2", 29 | "friendsofphp/php-cs-fixer": "^2.17", 30 | "psy/psysh": "^0.10.6", 31 | "guzzlehttp/guzzle": "^6.0|^7.0", 32 | "phpunit/phpunit": "^9.5", 33 | "mockery/mockery": "^1.5", 34 | "react/async": "^4 || ^3" 35 | }, 36 | "scripts": { 37 | "test": "php tests/Drivers/_server.php& HTTP_SERVER_PID=$!; ./vendor/bin/phpunit; kill $HTTP_SERVER_PID;", 38 | "test-discord": "./vendor/bin/phpunit --testsuite Discord", 39 | "test-drivers": "php tests/Drivers/_server.php& HTTP_SERVER_PID=$!; ./vendor/bin/phpunit --testsuite Drivers; kill $HTTP_SERVER_PID;", 40 | "test-coverage": "php tests/Drivers/_server.php& HTTP_SERVER_PID=$!; php -d xdebug.mode=coverage ./vendor/bin/phpunit --coverage-text; kill $HTTP_SERVER_PID;", 41 | "test-coverage-html": "php tests/Drivers/_server.php& HTTP_SERVER_PID=$!; php -d xdebug.mode=coverage ./vendor/bin/phpunit --coverage-html .phpunit.cache/cov-html; kill $HTTP_SERVER_PID;" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /examples/file-upload.php: -------------------------------------------------------------------------------- 1 | 'Hello!', 26 | ]), 27 | ['Content-Type' => 'application/json'] 28 | ); 29 | 30 | $imageField = new MultipartField( 31 | 'files[0]', 32 | file_get_contents('/path/to/image.png'), 33 | ['Content-Type' => 'image/png'], 34 | 'image.png' 35 | ); 36 | 37 | $multipart = new MultipartBody([ 38 | $jsonPayloadField, 39 | $imageField 40 | ]); 41 | 42 | $http->post( 43 | Endpoint::bind( 44 | Endpoint::CHANNEL_MESSAGES, 45 | 'Channel ID' 46 | ), 47 | $multipart 48 | )->then( 49 | function ($response) { 50 | // Do something with response.. 51 | }, 52 | function (Exception $e) { 53 | echo $e->getMessage(), PHP_EOL; 54 | } 55 | ); 56 | 57 | Loop::run(); 58 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 15 | 16 | 17 | tests/Discord 18 | 19 | 20 | 21 | tests/Drivers 22 | 23 | 24 | 25 | 27 | 28 | src 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/Discord/Bucket.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http; 13 | 14 | use Composer\InstalledVersions; 15 | use Psr\Http\Message\ResponseInterface; 16 | use Psr\Log\LoggerInterface; 17 | use React\EventLoop\LoopInterface; 18 | use React\EventLoop\TimerInterface; 19 | use SplQueue; 20 | 21 | /** 22 | * Represents a rate-limit bucket. 23 | * 24 | * @author David Cole 25 | */ 26 | class Bucket 27 | { 28 | /** 29 | * Request queue. 30 | * 31 | * @var SplQueue 32 | */ 33 | protected $queue; 34 | 35 | /** 36 | * Bucket name. 37 | * 38 | * @var string 39 | */ 40 | protected $name; 41 | 42 | /** 43 | * ReactPHP event loop. 44 | * 45 | * @var LoopInterface 46 | */ 47 | protected $loop; 48 | 49 | /** 50 | * HTTP logger. 51 | * 52 | * @var LoggerInterface 53 | */ 54 | protected $logger; 55 | 56 | /** 57 | * Callback for when a request is ready. 58 | * 59 | * @var callable 60 | */ 61 | protected $runRequest; 62 | 63 | /** 64 | * Whether we are checking the queue. 65 | * 66 | * @var bool 67 | */ 68 | protected $checkerRunning = false; 69 | 70 | /** 71 | * Number of requests allowed before reset. 72 | * 73 | * @var int 74 | */ 75 | protected $requestLimit; 76 | 77 | /** 78 | * Number of remaining requests before reset. 79 | * 80 | * @var int 81 | */ 82 | protected $requestRemaining; 83 | 84 | /** 85 | * Timer to reset the bucket. 86 | * 87 | * @var TimerInterface 88 | */ 89 | protected $resetTimer; 90 | 91 | /** 92 | * Whether react/promise v3 is used, if false, using v2 93 | */ 94 | protected $promiseV3 = true; 95 | 96 | /** 97 | * Bucket constructor. 98 | * 99 | * @param string $name 100 | * @param callable $runRequest 101 | */ 102 | public function __construct(string $name, LoopInterface $loop, LoggerInterface $logger, callable $runRequest) 103 | { 104 | $this->queue = new SplQueue; 105 | $this->name = $name; 106 | $this->loop = $loop; 107 | $this->logger = $logger; 108 | $this->runRequest = $runRequest; 109 | 110 | $this->promiseV3 = str_starts_with(InstalledVersions::getVersion('react/promise'), '3.'); 111 | } 112 | 113 | /** 114 | * Enqueue a request. 115 | * 116 | * @param Request $request 117 | */ 118 | public function enqueue(Request $request) 119 | { 120 | $this->queue->enqueue($request); 121 | $this->logger->debug($this.' queued '.$request); 122 | $this->checkQueue(); 123 | } 124 | 125 | /** 126 | * Checks for requests in the bucket. 127 | */ 128 | public function checkQueue() 129 | { 130 | // We are already checking the queue. 131 | if ($this->checkerRunning) { 132 | return; 133 | } 134 | 135 | $checkQueue = function () use (&$checkQueue) { 136 | // Check for rate-limits 137 | if ($this->requestRemaining < 1 && ! is_null($this->requestRemaining)) { 138 | $interval = 0; 139 | if ($this->resetTimer) { 140 | $interval = $this->resetTimer->getInterval() ?? 0; 141 | } 142 | $this->logger->info($this.' expecting rate limit, timer interval '.($interval * 1000).' ms'); 143 | $this->checkerRunning = false; 144 | $checkQueue = null; 145 | 146 | return; 147 | } 148 | 149 | // Queue is empty, job done. 150 | if ($this->queue->isEmpty()) { 151 | $this->checkerRunning = false; 152 | $checkQueue = null; 153 | 154 | return; 155 | } 156 | 157 | /** @var Request */ 158 | $request = $this->queue->dequeue(); 159 | 160 | // Promises v3 changed `->then` to behave as `->done` and removed `->then`. We still need the behaviour of `->done` in projects using v2 161 | ($this->runRequest)($request)->{$this->promiseV3 ? 'then' : 'done'}(function (ResponseInterface $response) use (&$checkQueue) { 162 | $resetAfter = (float) $response->getHeaderLine('X-Ratelimit-Reset-After'); 163 | $limit = $response->getHeaderLine('X-Ratelimit-Limit'); 164 | $remaining = $response->getHeaderLine('X-Ratelimit-Remaining'); 165 | 166 | if ($resetAfter) { 167 | $resetAfter = (float) $resetAfter; 168 | 169 | if ($this->resetTimer) { 170 | $this->loop->cancelTimer($this->resetTimer); 171 | } 172 | 173 | $this->resetTimer = $this->loop->addTimer($resetAfter, function () { 174 | // Reset requests remaining and check queue 175 | $this->requestRemaining = $this->requestLimit; 176 | $this->resetTimer = null; 177 | $this->checkQueue(); 178 | }); 179 | } 180 | 181 | // Check if rate-limit headers are present and store 182 | if (is_numeric($limit)) { 183 | $this->requestLimit = (int) $limit; 184 | } 185 | 186 | if (is_numeric($remaining)) { 187 | $this->requestRemaining = (int) $remaining; 188 | } 189 | 190 | // Check for more requests 191 | $checkQueue(); 192 | }, function ($rateLimit) use (&$checkQueue, $request) { 193 | if ($rateLimit instanceof RateLimit) { 194 | $this->queue->enqueue($request); 195 | 196 | // Bucket-specific rate-limit 197 | // Re-queue the request and wait the retry after time 198 | if (! $rateLimit->isGlobal()) { 199 | $this->loop->addTimer($rateLimit->getRetryAfter(), $checkQueue); 200 | } 201 | // Stop the queue checker for a global rate-limit. 202 | // Will be restarted when global rate-limit finished. 203 | else { 204 | $this->checkerRunning = false; 205 | $checkQueue = null; 206 | 207 | $this->logger->debug($this.' stopping queue checker'); 208 | } 209 | } else { 210 | $checkQueue(); 211 | } 212 | }); 213 | }; 214 | 215 | $this->checkerRunning = true; 216 | $checkQueue(); 217 | } 218 | 219 | /** 220 | * Converts a bucket to a user-readable string. 221 | * 222 | * @return string 223 | */ 224 | public function __toString() 225 | { 226 | return 'BUCKET '.$this->name; 227 | } 228 | } -------------------------------------------------------------------------------- /src/Discord/DriverInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http; 13 | 14 | use Psr\Http\Message\ResponseInterface; 15 | use React\Promise\PromiseInterface; 16 | 17 | /** 18 | * Interface for an HTTP driver. 19 | * 20 | * @author David Cole 21 | */ 22 | interface DriverInterface 23 | { 24 | /** 25 | * Runs a request. 26 | * 27 | * Returns a promise resolved with a PSR response interface. 28 | * 29 | * @param Request $request 30 | * 31 | * @return PromiseInterface 32 | */ 33 | public function runRequest(Request $request): PromiseInterface; 34 | } 35 | -------------------------------------------------------------------------------- /src/Discord/Drivers/Guzzle.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Drivers; 13 | 14 | use Discord\Http\DriverInterface; 15 | use Discord\Http\Request; 16 | use GuzzleHttp\Client; 17 | use GuzzleHttp\RequestOptions; 18 | use React\EventLoop\LoopInterface; 19 | use React\Promise\Deferred; 20 | use React\Promise\PromiseInterface; 21 | 22 | /** 23 | * guzzlehttp/guzzle driver for Discord HTTP client. (still with React Promise). 24 | * 25 | * @author SQKo 26 | */ 27 | class Guzzle implements DriverInterface 28 | { 29 | /** 30 | * ReactPHP event loop. 31 | * 32 | * @var LoopInterface|null 33 | */ 34 | protected $loop; 35 | 36 | /** 37 | * GuzzleHTTP/Guzzle client. 38 | * 39 | * @var Client 40 | */ 41 | protected $client; 42 | 43 | /** 44 | * Constructs the Guzzle driver. 45 | * 46 | * @param LoopInterface|null $loop 47 | * @param array $options 48 | */ 49 | public function __construct(?LoopInterface $loop = null, array $options = []) 50 | { 51 | $this->loop = $loop; 52 | 53 | // Allow 400 and 500 HTTP requests to be resolved rather than rejected. 54 | $options['http_errors'] = false; 55 | $this->client = new Client($options); 56 | } 57 | 58 | public function runRequest(Request $request): PromiseInterface 59 | { 60 | // Create a React promise 61 | $deferred = new Deferred(); 62 | $reactPromise = $deferred->promise(); 63 | 64 | $promise = $this->client->requestAsync($request->getMethod(), $request->getUrl(), [ 65 | RequestOptions::HEADERS => $request->getHeaders(), 66 | RequestOptions::BODY => $request->getContent(), 67 | ])->then([$deferred, 'resolve'], [$deferred, 'reject']); 68 | 69 | if ($this->loop) { 70 | $this->loop->futureTick([$promise, 'wait']); 71 | } else { 72 | $promise->wait(); 73 | } 74 | 75 | return $reactPromise; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Discord/Drivers/React.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Drivers; 13 | 14 | use Discord\Http\DriverInterface; 15 | use Discord\Http\Request; 16 | use React\EventLoop\LoopInterface; 17 | use React\Http\Browser; 18 | use React\Promise\PromiseInterface; 19 | use React\Socket\Connector; 20 | 21 | /** 22 | * react/http driver for Discord HTTP client. 23 | * 24 | * @author David Cole 25 | */ 26 | class React implements DriverInterface 27 | { 28 | /** 29 | * ReactPHP event loop. 30 | * 31 | * @var LoopInterface 32 | */ 33 | protected $loop; 34 | 35 | /** 36 | * ReactPHP/HTTP browser. 37 | * 38 | * @var Browser 39 | */ 40 | protected $browser; 41 | 42 | /** 43 | * Constructs the React driver. 44 | * 45 | * @param LoopInterface $loop 46 | * @param array $options 47 | */ 48 | public function __construct(LoopInterface $loop, array $options = []) 49 | { 50 | $this->loop = $loop; 51 | 52 | // Allow 400 and 500 HTTP requests to be resolved rather than rejected. 53 | $browser = new Browser($loop, new Connector($loop, $options)); 54 | $this->browser = $browser->withRejectErrorResponse(false); 55 | } 56 | 57 | public function runRequest(Request $request): PromiseInterface 58 | { 59 | return $this->browser->{$request->getMethod()}( 60 | $request->getUrl(), 61 | $request->getHeaders(), 62 | $request->getContent() 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Discord/Endpoint.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http; 13 | 14 | class Endpoint 15 | { 16 | // GET 17 | public const GATEWAY = 'gateway'; 18 | // GET 19 | public const GATEWAY_BOT = self::GATEWAY.'/bot'; 20 | 21 | // GET 22 | public const APPLICATION_SKUS = 'applications/:application_id/skus'; 23 | // GET, POST 24 | public const APPLICATION_EMOJIS = 'applications/:application_id/emojis'; 25 | // GET, PATCH, DELETE 26 | public const APPLICATION_EMOJI = 'applications/:application_id/emojis/:emoji_id'; 27 | // GET, POST 28 | public const APPLICATION_ENTITLEMENTS = 'applications/:application_id/entitlements'; 29 | // DELETE 30 | public const APPLICATION_ENTITLEMENT = self::APPLICATION_ENTITLEMENTS.'/:entitlement_id'; 31 | // POST 32 | public const APPLICATION_ENTITLEMENT_CONSUME = self::APPLICATION_ENTITLEMENT.'/consume'; 33 | // GET, POST, PUT 34 | public const GLOBAL_APPLICATION_COMMANDS = 'applications/:application_id/commands'; 35 | // GET, PATCH, DELETE 36 | public const GLOBAL_APPLICATION_COMMAND = self::GLOBAL_APPLICATION_COMMANDS.'/:command_id'; 37 | // GET, POST, PUT 38 | public const GUILD_APPLICATION_COMMANDS = 'applications/:application_id/guilds/:guild_id/commands'; 39 | // GET, PUT 40 | public const GUILD_APPLICATION_COMMANDS_PERMISSIONS = self::GUILD_APPLICATION_COMMANDS.'/permissions'; 41 | // GET, PATCH, DELETE 42 | public const GUILD_APPLICATION_COMMAND = self::GUILD_APPLICATION_COMMANDS.'/:command_id'; 43 | // GET, PUT 44 | public const GUILD_APPLICATION_COMMAND_PERMISSIONS = self::GUILD_APPLICATION_COMMANDS.'/:command_id/permissions'; 45 | // POST 46 | public const INTERACTION_RESPONSE = 'interactions/:interaction_id/:interaction_token/callback'; 47 | // POST 48 | public const CREATE_INTERACTION_FOLLOW_UP = 'webhooks/:application_id/:interaction_token'; 49 | // PATCH, DELETE 50 | public const ORIGINAL_INTERACTION_RESPONSE = self::CREATE_INTERACTION_FOLLOW_UP.'/messages/@original'; 51 | // PATCH, DELETE 52 | public const INTERACTION_FOLLOW_UP = self::CREATE_INTERACTION_FOLLOW_UP.'/messages/:message_id'; 53 | 54 | // GET 55 | public const SKU_SUBSCRIPTIONS = '/skus/:sku_id/subscriptions'; 56 | // GET 57 | public const SKU_SUBSCRIPTION = self::SKU_SUBSCRIPTIONS.'/:subscription_id'; 58 | 59 | // GET 60 | public const AUDIT_LOG = 'guilds/:guild_id/audit-logs'; 61 | 62 | // GET, PATCH, DELETE 63 | public const CHANNEL = 'channels/:channel_id'; 64 | // GET, POST 65 | public const CHANNEL_MESSAGES = self::CHANNEL.'/messages'; 66 | // GET, PATCH, DELETE 67 | public const CHANNEL_MESSAGE = self::CHANNEL.'/messages/:message_id'; 68 | // POST 69 | public const CHANNEL_CROSSPOST_MESSAGE = self::CHANNEL.'/messages/:message_id/crosspost'; 70 | // POST 71 | public const CHANNEL_MESSAGES_BULK_DELETE = self::CHANNEL.'/messages/bulk-delete'; 72 | // PUT, DELETE 73 | public const CHANNEL_PERMISSIONS = self::CHANNEL.'/permissions/:overwrite_id'; 74 | // GET, POST 75 | public const CHANNEL_INVITES = self::CHANNEL.'/invites'; 76 | // POST 77 | public const CHANNEL_FOLLOW = self::CHANNEL.'/followers'; 78 | // POST 79 | public const CHANNEL_TYPING = self::CHANNEL.'/typing'; 80 | // GET 81 | public const CHANNEL_PINS = self::CHANNEL.'/pins'; 82 | // PUT, DELETE 83 | public const CHANNEL_PIN = self::CHANNEL.'/pins/:message_id'; 84 | // POST 85 | public const CHANNEL_THREADS = self::CHANNEL.'/threads'; 86 | // POST 87 | public const CHANNEL_MESSAGE_THREADS = self::CHANNEL_MESSAGE.'/threads'; 88 | // GET 89 | public const CHANNEL_THREADS_ARCHIVED_PUBLIC = self::CHANNEL_THREADS.'/archived/public'; 90 | // GET 91 | public const CHANNEL_THREADS_ARCHIVED_PRIVATE = self::CHANNEL_THREADS.'/archived/private'; 92 | // GET 93 | public const CHANNEL_THREADS_ARCHIVED_PRIVATE_ME = self::CHANNEL.'/users/@me/threads/archived/private'; 94 | // POST 95 | public const CHANNEL_SEND_SOUNDBOARD_SOUND = self::CHANNEL . '/send-soundboard-sound'; 96 | 97 | // GET, PATCH, DELETE 98 | public const THREAD = 'channels/:thread_id'; 99 | // GET 100 | public const THREAD_MEMBERS = self::THREAD.'/thread-members'; 101 | // GET, PUT, DELETE 102 | public const THREAD_MEMBER = self::THREAD_MEMBERS.'/:user_id'; 103 | // PUT, DELETE 104 | public const THREAD_MEMBER_ME = self::THREAD_MEMBERS.'/@me'; 105 | 106 | // GET, DELETE 107 | public const MESSAGE_REACTION_ALL = self::CHANNEL.'/messages/:message_id/reactions'; 108 | // GET, DELETE 109 | public const MESSAGE_REACTION_EMOJI = self::CHANNEL.'/messages/:message_id/reactions/:emoji'; 110 | // PUT, DELETE 111 | public const OWN_MESSAGE_REACTION = self::CHANNEL.'/messages/:message_id/reactions/:emoji/@me'; 112 | // DELETE 113 | public const USER_MESSAGE_REACTION = self::CHANNEL.'/messages/:message_id/reactions/:emoji/:user_id'; 114 | 115 | // GET 116 | protected const MESSAGE_POLL = self::CHANNEL.'/polls/:message_id'; 117 | // GET 118 | public const MESSAGE_POLL_ANSWER = self::MESSAGE_POLL.'/answers/:answer_id'; 119 | // POST 120 | public const MESSAGE_POLL_EXPIRE = self::MESSAGE_POLL.'/expire'; 121 | 122 | // GET, POST 123 | public const CHANNEL_WEBHOOKS = self::CHANNEL.'/webhooks'; 124 | 125 | // POST 126 | public const GUILDS = 'guilds'; 127 | // GET, PATCH, DELETE 128 | public const GUILD = 'guilds/:guild_id'; 129 | // GET, POST, PATCH 130 | public const GUILD_CHANNELS = self::GUILD.'/channels'; 131 | // GET 132 | public const GUILD_THREADS_ACTIVE = self::GUILD.'/threads/active'; 133 | 134 | // GET 135 | public const GUILD_MEMBERS = self::GUILD.'/members'; 136 | // GET 137 | public const GUILD_MEMBERS_SEARCH = self::GUILD.'/members/search'; 138 | // GET, PATCH, PUT, DELETE 139 | public const GUILD_MEMBER = self::GUILD.'/members/:user_id'; 140 | // PATCH 141 | public const GUILD_MEMBER_SELF = self::GUILD.'/members/@me'; 142 | /** @deprecated 9.0.9 Use `GUILD_MEMBER_SELF` */ 143 | public const GUILD_MEMBER_SELF_NICK = self::GUILD.'/members/@me/nick'; 144 | // PUT, DELETE 145 | public const GUILD_MEMBER_ROLE = self::GUILD.'/members/:user_id/roles/:role_id'; 146 | 147 | // GET 148 | public const GUILD_BANS = self::GUILD.'/bans'; 149 | // GET, PUT, DELETE 150 | public const GUILD_BAN = self::GUILD.'/bans/:user_id'; 151 | 152 | // GET, PATCH 153 | public const GUILD_ROLES = self::GUILD.'/roles'; 154 | // GET, POST, PATCH, DELETE 155 | public const GUILD_ROLE = self::GUILD.'/roles/:role_id'; 156 | 157 | // POST 158 | public const GUILD_MFA = self::GUILD.'/mfa'; 159 | 160 | // GET, POST 161 | public const GUILD_INVITES = self::GUILD.'/invites'; 162 | 163 | // GET, POST 164 | public const GUILD_INTEGRATIONS = self::GUILD.'/integrations'; 165 | // PATCH, DELETE 166 | public const GUILD_INTEGRATION = self::GUILD.'/integrations/:integration_id'; 167 | // POST 168 | public const GUILD_INTEGRATION_SYNC = self::GUILD.'/integrations/:integration_id/sync'; 169 | 170 | // GET, POST 171 | public const GUILD_EMOJIS = self::GUILD.'/emojis'; 172 | // GET, PATCH, DELETE 173 | public const GUILD_EMOJI = self::GUILD.'/emojis/:emoji_id'; 174 | 175 | // GET 176 | public const GUILD_PREVIEW = self::GUILD.'/preview'; 177 | // GET, POST 178 | public const GUILD_PRUNE = self::GUILD.'/prune'; 179 | // GET 180 | public const GUILD_REGIONS = self::GUILD.'/regions'; 181 | // GET, PATCH 182 | public const GUILD_WIDGET_SETTINGS = self::GUILD.'/widget'; 183 | // GET 184 | public const GUILD_WIDGET = self::GUILD.'/widget.json'; 185 | // GET 186 | public const GUILD_WIDGET_IMAGE = self::GUILD.'/widget.png'; 187 | // GET, PATCH 188 | public const GUILD_WELCOME_SCREEN = self::GUILD.'/welcome-screen'; 189 | // GET 190 | public const GUILD_ONBOARDING = self::GUILD.'/onboarding'; 191 | // GET, PATCH 192 | public const GUILD_USER_CURRENT_VOICE_STATE = self::GUILD.'/voice-states/@me'; 193 | // GET, PATCH 194 | public const GUILD_USER_VOICE_STATE = self::GUILD.'/voice-states/:user_id'; 195 | // GET 196 | public const GUILD_VANITY_URL = self::GUILD.'/vanity-url'; 197 | // GET, PATCH 198 | public const GUILD_MEMBERSHIP_SCREENING = self::GUILD.'/member-verification'; 199 | // GET 200 | public const GUILD_WEBHOOKS = self::GUILD.'/webhooks'; 201 | 202 | // GET, POST 203 | public const GUILD_STICKERS = self::GUILD.'/stickers'; 204 | // GET, PATCH, DELETE 205 | public const GUILD_STICKER = self::GUILD.'/stickers/:sticker_id'; 206 | 207 | // GET 208 | public const STICKER = 'stickers/:sticker_id'; 209 | // GET 210 | public const STICKER_PACKS = 'sticker-packs'; 211 | 212 | // GET, POST 213 | public const GUILD_SCHEDULED_EVENTS = self::GUILD.'/scheduled-events'; 214 | // GET, PATCH, DELETE 215 | public const GUILD_SCHEDULED_EVENT = self::GUILD.'/scheduled-events/:guild_scheduled_event_id'; 216 | // GET 217 | public const GUILD_SCHEDULED_EVENT_USERS = self::GUILD.'/scheduled-events/:guild_scheduled_event_id/users'; 218 | 219 | // GET, POST 220 | public const GUILD_SOUNDBOARD_SOUNDS = self::GUILD.'/soundboard-sounds'; 221 | // GET, PATCH, DELETE 222 | public const GUILD_SOUNDBOARD_SOUND = self::GUILD.'/soundboard-sounds/:sound_id'; 223 | 224 | // GET, DELETE 225 | public const INVITE = 'invites/:code'; 226 | 227 | // POST 228 | public const STAGE_INSTANCES = 'stage-instances'; 229 | // GET, PATCH, DELETE 230 | public const STAGE_INSTANCE = 'stage-instances/:channel_id'; 231 | 232 | // GET, POST 233 | public const GUILDS_TEMPLATE = self::GUILDS.'/templates/:template_code'; 234 | // GET, POST 235 | public const GUILD_TEMPLATES = self::GUILD.'/templates'; 236 | // PUT, PATCH, DELETE 237 | public const GUILD_TEMPLATE = self::GUILD.'/templates/:template_code'; 238 | 239 | // GET, POST 240 | public const GUILD_AUTO_MODERATION_RULES = self::GUILD.'/auto-moderation/rules'; 241 | // GET, PATCH, DELETE 242 | public const GUILD_AUTO_MODERATION_RULE = self::GUILD.'/auto-moderation/rules/:auto_moderation_rule_id'; 243 | 244 | // GET 245 | public const SOUNDBOARD_DEFAULT_SOUNDS = 'soundboard-default-sounds'; 246 | 247 | // GET, PATCH 248 | public const USER_CURRENT = 'users/@me'; 249 | // GET 250 | public const USER = 'users/:user_id'; 251 | // GET 252 | public const USER_CURRENT_GUILDS = self::USER_CURRENT.'/guilds'; 253 | // DELETE 254 | public const USER_CURRENT_GUILD = self::USER_CURRENT.'/guilds/:guild_id'; 255 | // GET 256 | public const USER_CURRENT_MEMBER = self::USER_CURRENT.'/guilds/:guild_id/member'; 257 | // GET, POST 258 | public const USER_CURRENT_CHANNELS = self::USER_CURRENT.'/channels'; 259 | // GET 260 | public const USER_CURRENT_CONNECTIONS = self::USER_CURRENT.'/connections'; 261 | // GET, PUT 262 | public const USER_CURRENT_APPLICATION_ROLE_CONNECTION = self::USER_CURRENT.'/applications/:application_id/role-connection'; 263 | // GET 264 | public const APPLICATION_CURRENT = 'applications/@me'; 265 | 266 | // GET, PATCH, DELETE 267 | public const WEBHOOK = 'webhooks/:webhook_id'; 268 | // GET, PATCH, DELETE 269 | public const WEBHOOK_TOKEN = 'webhooks/:webhook_id/:webhook_token'; 270 | // POST 271 | public const WEBHOOK_EXECUTE = self::WEBHOOK_TOKEN; 272 | // POST 273 | public const WEBHOOK_EXECUTE_SLACK = self::WEBHOOK_EXECUTE.'/slack'; 274 | // POST 275 | public const WEBHOOK_EXECUTE_GITHUB = self::WEBHOOK_EXECUTE.'/github'; 276 | // PATCH, DELETE 277 | public const WEBHOOK_MESSAGE = self::WEBHOOK_TOKEN.'/messages/:message_id'; 278 | 279 | // GET, PUT 280 | public const APPLICATION_ROLE_CONNECTION_METADATA = 'applications/:application_id/role-connections/metadata'; 281 | 282 | /** 283 | * Regex to identify parameters in endpoints. 284 | * 285 | * @var string 286 | */ 287 | public const REGEX = '/:([^\/]*)/'; 288 | 289 | /** 290 | * A list of parameters considered 'major' by Discord. 291 | * 292 | * @see https://discord.com/developers/docs/topics/rate-limits 293 | * @var string[] 294 | */ 295 | public const MAJOR_PARAMETERS = ['channel_id', 'guild_id', 'webhook_id', 'thread_id']; 296 | 297 | /** 298 | * The string version of the endpoint, including all parameters. 299 | * 300 | * @var string 301 | */ 302 | protected $endpoint; 303 | 304 | /** 305 | * Array of placeholders to be replaced in the endpoint. 306 | * 307 | * @var string[] 308 | */ 309 | protected $vars = []; 310 | 311 | /** 312 | * Array of arguments to substitute into the endpoint. 313 | * 314 | * @var string[] 315 | */ 316 | protected $args = []; 317 | 318 | /** 319 | * Array of query data to be appended 320 | * to the end of the endpoint with `http_build_query`. 321 | * 322 | * @var array 323 | */ 324 | protected $query = []; 325 | 326 | /** 327 | * Creates an endpoint class. 328 | * 329 | * @param string $endpoint 330 | */ 331 | public function __construct(string $endpoint) 332 | { 333 | $this->endpoint = $endpoint; 334 | 335 | if (preg_match_all(self::REGEX, $endpoint, $vars)) { 336 | $this->vars = $vars[1] ?? []; 337 | } 338 | } 339 | 340 | /** 341 | * Binds a list of arguments to the endpoint. 342 | * 343 | * @param string[] ...$args 344 | * @return this 345 | */ 346 | public function bindArgs(...$args): self 347 | { 348 | for ($i = 0; $i < count($this->vars) && $i < count($args); $i++) { 349 | $this->args[$this->vars[$i]] = $args[$i]; 350 | } 351 | 352 | return $this; 353 | } 354 | 355 | /** 356 | * Binds an associative array to the endpoint. 357 | * 358 | * @param string[] $args 359 | * @return this 360 | */ 361 | public function bindAssoc(array $args): self 362 | { 363 | $this->args = array_merge($this->args, $args); 364 | 365 | return $this; 366 | } 367 | 368 | /** 369 | * Adds a key-value query pair to the endpoint. 370 | * 371 | * @param string $key 372 | * @param string|bool $value 373 | */ 374 | public function addQuery(string $key, $value): void 375 | { 376 | if (! is_bool($value)) { 377 | $value = (string) $value; 378 | } 379 | 380 | $this->query[$key] = $value; 381 | } 382 | 383 | /** 384 | * Converts the endpoint into the absolute endpoint with 385 | * placeholders replaced. 386 | * 387 | * Passing a true boolean in will only replace the major parameters. 388 | * Used for rate limit buckets. 389 | * 390 | * @param bool $onlyMajorParameters 391 | * @return string 392 | */ 393 | public function toAbsoluteEndpoint(bool $onlyMajorParameters = false): string 394 | { 395 | $endpoint = $this->endpoint; 396 | 397 | foreach ($this->vars as $var) { 398 | if (! isset($this->args[$var]) || ($onlyMajorParameters && ! $this->isMajorParameter($var))) { 399 | continue; 400 | } 401 | 402 | $endpoint = str_replace(":{$var}", $this->args[$var], $endpoint); 403 | } 404 | 405 | if (! $onlyMajorParameters && count($this->query) > 0) { 406 | $endpoint .= '?'.http_build_query($this->query); 407 | } 408 | 409 | return $endpoint; 410 | } 411 | 412 | /** 413 | * Converts the endpoint to a string. 414 | * Alias of ->toAbsoluteEndpoint();. 415 | * 416 | * @return string 417 | */ 418 | public function __toString(): string 419 | { 420 | return $this->toAbsoluteEndpoint(); 421 | } 422 | 423 | /** 424 | * Creates an endpoint class and binds arguments to 425 | * the newly created instance. 426 | * 427 | * @param string $endpoint 428 | * @param string[] $args 429 | * @return Endpoint 430 | */ 431 | public static function bind(string $endpoint, ...$args) 432 | { 433 | $endpoint = new Endpoint($endpoint); 434 | $endpoint->bindArgs(...$args); 435 | 436 | return $endpoint; 437 | } 438 | 439 | /** 440 | * Checks if a parameter is a major parameter. 441 | * 442 | * @param string $param 443 | * @return bool 444 | */ 445 | private static function isMajorParameter(string $param): bool 446 | { 447 | return in_array($param, self::MAJOR_PARAMETERS); 448 | } 449 | } 450 | -------------------------------------------------------------------------------- /src/Discord/Exceptions/BadRequestException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Exceptions; 13 | 14 | /** 15 | * Thrown when a request to Discord's REST API returned ClientErrorResponse. 16 | * 17 | * @author SQKo 18 | */ 19 | class BadRequestException extends RequestFailedException 20 | { 21 | protected $code = 400; 22 | } 23 | -------------------------------------------------------------------------------- /src/Discord/Exceptions/ContentTooLongException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Exceptions; 13 | 14 | /** 15 | * Thrown when the Discord servers return `content longer than 2000 characters` after 16 | * a REST request. The user must use WebSockets to obtain this data if they need it. 17 | * 18 | * @author David Cole 19 | */ 20 | class ContentTooLongException extends RequestFailedException 21 | { 22 | } 23 | -------------------------------------------------------------------------------- /src/Discord/Exceptions/InvalidTokenException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Exceptions; 13 | 14 | /** 15 | * Thrown when an invalid token is provided to a Discord endpoint. 16 | * 17 | * @author David Cole 18 | */ 19 | class InvalidTokenException extends RequestFailedException 20 | { 21 | protected $code = 401; 22 | } 23 | -------------------------------------------------------------------------------- /src/Discord/Exceptions/MethodNotAllowedException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Exceptions; 13 | 14 | /** 15 | * Thrown when a request to Discord's REST API method is invalid. 16 | * 17 | * @author SQKo 18 | */ 19 | class MethodNotAllowedException extends RequestFailedException 20 | { 21 | protected $code = 405; 22 | } 23 | -------------------------------------------------------------------------------- /src/Discord/Exceptions/NoPermissionsException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Exceptions; 13 | 14 | /** 15 | * Thrown when you do not have permissions to do something. 16 | * 17 | * @author David Cole 18 | */ 19 | class NoPermissionsException extends RequestFailedException 20 | { 21 | protected $code = 403; 22 | } 23 | -------------------------------------------------------------------------------- /src/Discord/Exceptions/NotFoundException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Exceptions; 13 | 14 | /** 15 | * Thrown when a 404 Not Found response is received. 16 | * 17 | * @author David Cole 18 | */ 19 | class NotFoundException extends RequestFailedException 20 | { 21 | protected $code = 404; 22 | } 23 | -------------------------------------------------------------------------------- /src/Discord/Exceptions/RateLimitException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Exceptions; 13 | 14 | /** 15 | * Thrown when a request to Discord's REST API got rate limited and the library 16 | * does not know how to handle. 17 | * 18 | * @author SQKo 19 | */ 20 | class RateLimitException extends RequestFailedException 21 | { 22 | protected $code = 429; 23 | } 24 | -------------------------------------------------------------------------------- /src/Discord/Exceptions/RequestFailedException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Exceptions; 13 | 14 | use RuntimeException; 15 | 16 | /** 17 | * Thrown when a request to Discord's REST API fails. 18 | * 19 | * @author David Cole 20 | */ 21 | class RequestFailedException extends RuntimeException 22 | { 23 | } 24 | -------------------------------------------------------------------------------- /src/Discord/Http.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http; 13 | 14 | use Composer\InstalledVersions; 15 | use Discord\Http\Exceptions\BadRequestException; 16 | use Discord\Http\Exceptions\ContentTooLongException; 17 | use Discord\Http\Exceptions\InvalidTokenException; 18 | use Discord\Http\Exceptions\MethodNotAllowedException; 19 | use Discord\Http\Exceptions\NoPermissionsException; 20 | use Discord\Http\Exceptions\NotFoundException; 21 | use Discord\Http\Exceptions\RateLimitException; 22 | use Discord\Http\Exceptions\RequestFailedException; 23 | use Discord\Http\Multipart\MultipartBody; 24 | use Psr\Http\Message\ResponseInterface; 25 | use Psr\Log\LoggerInterface; 26 | use React\EventLoop\LoopInterface; 27 | use React\Promise\Deferred; 28 | use React\Promise\PromiseInterface; 29 | use SplQueue; 30 | 31 | /** 32 | * Discord HTTP client. 33 | * 34 | * @author David Cole 35 | */ 36 | class Http 37 | { 38 | /** 39 | * DiscordPHP-Http version. 40 | * 41 | * @var string 42 | */ 43 | public const VERSION = 'v10.3.0'; 44 | 45 | /** 46 | * Current Discord HTTP API version. 47 | * 48 | * @var string 49 | */ 50 | public const HTTP_API_VERSION = 10; 51 | 52 | /** 53 | * Discord API base URL. 54 | * 55 | * @var string 56 | */ 57 | public const BASE_URL = 'https://discord.com/api/v'.self::HTTP_API_VERSION; 58 | 59 | /** 60 | * The number of concurrent requests which can 61 | * be executed. 62 | * 63 | * @var int 64 | */ 65 | public const CONCURRENT_REQUESTS = 5; 66 | 67 | /** 68 | * Authentication token. 69 | * 70 | * @var string 71 | */ 72 | private $token; 73 | 74 | /** 75 | * Logger for HTTP requests. 76 | * 77 | * @var LoggerInterface 78 | */ 79 | protected $logger; 80 | 81 | /** 82 | * HTTP driver. 83 | * 84 | * @var DriverInterface 85 | */ 86 | protected $driver; 87 | 88 | /** 89 | * ReactPHP event loop. 90 | * 91 | * @var LoopInterface 92 | */ 93 | protected $loop; 94 | 95 | /** 96 | * Array of request buckets. 97 | * 98 | * @var Bucket[] 99 | */ 100 | protected $buckets = []; 101 | 102 | /** 103 | * The current rate-limit. 104 | * 105 | * @var RateLimit 106 | */ 107 | protected $rateLimit; 108 | 109 | /** 110 | * Timer that resets the current global rate-limit. 111 | * 112 | * @var TimerInterface 113 | */ 114 | protected $rateLimitReset; 115 | 116 | /** 117 | * Request queue to prevent API 118 | * overload. 119 | * 120 | * @var SplQueue 121 | */ 122 | protected $queue; 123 | 124 | /** 125 | * Number of requests that are waiting for a response. 126 | * 127 | * @var int 128 | */ 129 | protected $waiting = 0; 130 | 131 | 132 | /** 133 | * Whether react/promise v3 is used, if false, using v2 134 | */ 135 | protected $promiseV3 = true; 136 | 137 | /** 138 | * Http wrapper constructor. 139 | * 140 | * @param string $token 141 | * @param LoopInterface $loop 142 | * @param DriverInterface|null $driver 143 | */ 144 | public function __construct(string $token, LoopInterface $loop, LoggerInterface $logger, ?DriverInterface $driver = null) 145 | { 146 | $this->token = $token; 147 | $this->loop = $loop; 148 | $this->logger = $logger; 149 | $this->driver = $driver; 150 | $this->queue = new SplQueue; 151 | 152 | $this->promiseV3 = str_starts_with(InstalledVersions::getVersion('react/promise'), '3.'); 153 | } 154 | 155 | /** 156 | * Sets the driver of the HTTP client. 157 | * 158 | * @param DriverInterface $driver 159 | */ 160 | public function setDriver(DriverInterface $driver): void 161 | { 162 | $this->driver = $driver; 163 | } 164 | 165 | /** 166 | * Runs a GET request. 167 | * 168 | * @param string|Endpoint $url 169 | * @param mixed $content 170 | * @param array $headers 171 | * 172 | * @return PromiseInterface 173 | */ 174 | public function get($url, $content = null, array $headers = []): PromiseInterface 175 | { 176 | if (! ($url instanceof Endpoint)) { 177 | $url = Endpoint::bind($url); 178 | } 179 | 180 | return $this->queueRequest('get', $url, $content, $headers); 181 | } 182 | 183 | /** 184 | * Runs a POST request. 185 | * 186 | * @param string|Endpoint $url 187 | * @param mixed $content 188 | * @param array $headers 189 | * 190 | * @return PromiseInterface 191 | */ 192 | public function post($url, $content = null, array $headers = []): PromiseInterface 193 | { 194 | if (! ($url instanceof Endpoint)) { 195 | $url = Endpoint::bind($url); 196 | } 197 | 198 | return $this->queueRequest('post', $url, $content, $headers); 199 | } 200 | 201 | /** 202 | * Runs a PUT request. 203 | * 204 | * @param string|Endpoint $url 205 | * @param mixed $content 206 | * @param array $headers 207 | * 208 | * @return PromiseInterface 209 | */ 210 | public function put($url, $content = null, array $headers = []): PromiseInterface 211 | { 212 | if (! ($url instanceof Endpoint)) { 213 | $url = Endpoint::bind($url); 214 | } 215 | 216 | return $this->queueRequest('put', $url, $content, $headers); 217 | } 218 | 219 | /** 220 | * Runs a PATCH request. 221 | * 222 | * @param string|Endpoint $url 223 | * @param mixed $content 224 | * @param array $headers 225 | * 226 | * @return PromiseInterface 227 | */ 228 | public function patch($url, $content = null, array $headers = []): PromiseInterface 229 | { 230 | if (! ($url instanceof Endpoint)) { 231 | $url = Endpoint::bind($url); 232 | } 233 | 234 | return $this->queueRequest('patch', $url, $content, $headers); 235 | } 236 | 237 | /** 238 | * Runs a DELETE request. 239 | * 240 | * @param string|Endpoint $url 241 | * @param mixed $content 242 | * @param array $headers 243 | * 244 | * @return PromiseInterface 245 | */ 246 | public function delete($url, $content = null, array $headers = []): PromiseInterface 247 | { 248 | if (! ($url instanceof Endpoint)) { 249 | $url = Endpoint::bind($url); 250 | } 251 | 252 | return $this->queueRequest('delete', $url, $content, $headers); 253 | } 254 | 255 | /** 256 | * Builds and queues a request. 257 | * 258 | * @param string $method 259 | * @param Endpoint $url 260 | * @param mixed $content 261 | * @param array $headers 262 | * 263 | * @return PromiseInterface 264 | */ 265 | public function queueRequest(string $method, Endpoint $url, $content, array $headers = []): PromiseInterface 266 | { 267 | $deferred = new Deferred(); 268 | 269 | if (is_null($this->driver)) { 270 | $deferred->reject(new \Exception('HTTP driver is missing.')); 271 | 272 | return $deferred->promise(); 273 | } 274 | 275 | $headers = array_merge($headers, [ 276 | 'User-Agent' => $this->getUserAgent(), 277 | 'Authorization' => $this->token, 278 | 'X-Ratelimit-Precision' => 'millisecond', 279 | ]); 280 | 281 | $baseHeaders = [ 282 | 'User-Agent' => $this->getUserAgent(), 283 | 'Authorization' => $this->token, 284 | 'X-Ratelimit-Precision' => 'millisecond', 285 | ]; 286 | 287 | if (! is_null($content) && ! isset($headers['Content-Type'])) { 288 | $baseHeaders = array_merge( 289 | $baseHeaders, 290 | $this->guessContent($content) 291 | ); 292 | } 293 | 294 | $headers = array_merge($baseHeaders, $headers); 295 | 296 | $request = new Request($deferred, $method, $url, $content ?? '', $headers); 297 | $this->sortIntoBucket($request); 298 | 299 | return $deferred->promise(); 300 | } 301 | 302 | /** 303 | * Guesses the headers and transforms the content of a request. 304 | * 305 | * @param mixed $content 306 | */ 307 | protected function guessContent(&$content) 308 | { 309 | if ($content instanceof MultipartBody) { 310 | $headers = $content->getHeaders(); 311 | $content = (string) $content; 312 | 313 | return $headers; 314 | } 315 | 316 | $content = json_encode($content); 317 | 318 | return [ 319 | 'Content-Type' => 'application/json', 320 | 'Content-Length' => strlen($content), 321 | ]; 322 | } 323 | 324 | /** 325 | * Executes a request. 326 | * 327 | * @param Request $request 328 | * @param Deferred|null $deferred 329 | * 330 | * @return PromiseInterface 331 | */ 332 | protected function executeRequest(Request $request, ?Deferred $deferred = null): PromiseInterface 333 | { 334 | if ($deferred === null) { 335 | $deferred = new Deferred(); 336 | } 337 | 338 | if ($this->rateLimit) { 339 | $deferred->reject($this->rateLimit); 340 | 341 | return $deferred->promise(); 342 | } 343 | 344 | // Promises v3 changed `->then` to behave as `->done` and removed `->then`. We still need the behaviour of `->done` in projects using v2 345 | $this->driver->runRequest($request)->{$this->promiseV3 ? 'then' : 'done'}(function (ResponseInterface $response) use ($request, $deferred) { 346 | $data = json_decode((string) $response->getBody()); 347 | $statusCode = $response->getStatusCode(); 348 | 349 | // Discord Rate-limit 350 | if ($statusCode == 429) { 351 | if (! isset($data->global)) { 352 | if ($response->hasHeader('X-RateLimit-Global')) { 353 | $data->global = $response->getHeader('X-RateLimit-Global')[0] == 'true'; 354 | } else { 355 | // Some other 429 356 | $this->logger->error($request.' does not contain global rate-limit value'); 357 | $rateLimitError = new RateLimitException('No rate limit global response', $statusCode); 358 | $deferred->reject($rateLimitError); 359 | $request->getDeferred()->reject($rateLimitError); 360 | 361 | return; 362 | } 363 | } 364 | 365 | if (! isset($data->retry_after)) { 366 | if ($response->hasHeader('Retry-After')) { 367 | $data->retry_after = $response->getHeader('Retry-After')[0]; 368 | } else { 369 | // Some other 429 370 | $this->logger->error($request.' does not contain retry after rate-limit value'); 371 | $rateLimitError = new RateLimitException('No rate limit retry after response', $statusCode); 372 | $deferred->reject($rateLimitError); 373 | $request->getDeferred()->reject($rateLimitError); 374 | 375 | return; 376 | } 377 | } 378 | 379 | $rateLimit = new RateLimit($data->global, $data->retry_after); 380 | $this->logger->warning($request.' hit rate-limit: '.$rateLimit); 381 | 382 | if ($rateLimit->isGlobal() && ! $this->rateLimit) { 383 | $this->rateLimit = $rateLimit; 384 | $this->rateLimitReset = $this->loop->addTimer($rateLimit->getRetryAfter(), function () { 385 | $this->rateLimit = null; 386 | $this->rateLimitReset = null; 387 | $this->logger->info('global rate-limit reset'); 388 | 389 | // Loop through all buckets and check for requests 390 | foreach ($this->buckets as $bucket) { 391 | $bucket->checkQueue(); 392 | } 393 | }); 394 | } 395 | 396 | $deferred->reject($rateLimit->isGlobal() ? $this->rateLimit : $rateLimit); 397 | } 398 | // Bad Gateway 399 | // Cloudflare SSL Handshake error 400 | // Push to the back of the bucket to be retried. 401 | elseif ($statusCode == 502 || $statusCode == 525) { 402 | $this->logger->warning($request.' 502/525 - retrying request'); 403 | 404 | $this->executeRequest($request, $deferred); 405 | } 406 | // Any other unsuccessful status codes 407 | elseif ($statusCode < 200 || $statusCode >= 300) { 408 | $error = $this->handleError($response); 409 | $this->logger->warning($request.' failed: '.$error); 410 | 411 | $deferred->reject($error); 412 | $request->getDeferred()->reject($error); 413 | } 414 | // All is well 415 | else { 416 | $this->logger->debug($request.' successful'); 417 | 418 | $deferred->resolve($response); 419 | $request->getDeferred()->resolve($data); 420 | } 421 | }, function (\Exception $e) use ($request, $deferred) { 422 | $this->logger->warning($request.' failed: '.$e->getMessage()); 423 | 424 | $deferred->reject($e); 425 | $request->getDeferred()->reject($e); 426 | }); 427 | 428 | return $deferred->promise(); 429 | } 430 | 431 | /** 432 | * Sorts a request into a bucket. 433 | * 434 | * @param Request $request 435 | */ 436 | protected function sortIntoBucket(Request $request): void 437 | { 438 | $bucket = $this->getBucket($request->getBucketID()); 439 | $bucket->enqueue($request); 440 | } 441 | 442 | /** 443 | * Gets a bucket. 444 | * 445 | * @param string $key 446 | * 447 | * @return Bucket 448 | */ 449 | protected function getBucket(string $key): Bucket 450 | { 451 | if (! isset($this->buckets[$key])) { 452 | $bucket = new Bucket($key, $this->loop, $this->logger, function (Request $request) { 453 | $deferred = new Deferred(); 454 | $this->queue->enqueue([$request, $deferred]); 455 | $this->checkQueue(); 456 | 457 | return $deferred->promise(); 458 | }); 459 | 460 | $this->buckets[$key] = $bucket; 461 | } 462 | 463 | return $this->buckets[$key]; 464 | } 465 | 466 | /** 467 | * Checks the request queue to see if more requests can be 468 | * sent out. 469 | */ 470 | protected function checkQueue(): void 471 | { 472 | if ($this->waiting >= static::CONCURRENT_REQUESTS || $this->queue->isEmpty()) { 473 | $this->logger->debug('http not checking', ['waiting' => $this->waiting, 'empty' => $this->queue->isEmpty()]); 474 | 475 | return; 476 | } 477 | 478 | /** 479 | * @var Request $request 480 | * @var Deferred $deferred 481 | */ 482 | [$request, $deferred] = $this->queue->dequeue(); 483 | ++$this->waiting; 484 | 485 | $this->executeRequest($request)->then(function ($result) use ($deferred) { 486 | --$this->waiting; 487 | $this->checkQueue(); 488 | $deferred->resolve($result); 489 | }, function ($e) use ($deferred) { 490 | --$this->waiting; 491 | $this->checkQueue(); 492 | $deferred->reject($e); 493 | }); 494 | } 495 | 496 | /** 497 | * Returns an exception based on the request. 498 | * 499 | * @param ResponseInterface $response 500 | * 501 | * @return \Throwable 502 | */ 503 | public function handleError(ResponseInterface $response): \Throwable 504 | { 505 | $reason = $response->getReasonPhrase().' - '; 506 | 507 | $errorBody = (string) $response->getBody(); 508 | $errorCode = $response->getStatusCode(); 509 | 510 | // attempt to prettyify the response content 511 | if (($content = json_decode($errorBody)) !== null) { 512 | if (! empty($content->code)) { 513 | $errorCode = $content->code; 514 | } 515 | $reason .= json_encode($content, JSON_PRETTY_PRINT); 516 | } else { 517 | $reason .= $errorBody; 518 | } 519 | 520 | switch ($response->getStatusCode()) { 521 | case 400: 522 | return new BadRequestException($reason, $errorCode); 523 | case 401: 524 | return new InvalidTokenException($reason, $errorCode); 525 | case 403: 526 | return new NoPermissionsException($reason, $errorCode); 527 | case 404: 528 | return new NotFoundException($reason, $errorCode); 529 | case 405: 530 | return new MethodNotAllowedException($reason, $errorCode); 531 | case 500: 532 | if (strpos(strtolower($errorBody), 'longer than 2000 characters') !== false || 533 | strpos(strtolower($errorBody), 'string value is too long') !== false) { 534 | // Response was longer than 2000 characters and was blocked by Discord. 535 | return new ContentTooLongException('Response was more than 2000 characters. Use another method to get this data.', $errorCode); 536 | } 537 | default: 538 | return new RequestFailedException($reason, $errorCode); 539 | } 540 | } 541 | 542 | /** 543 | * Returns the User-Agent of the HTTP client. 544 | * 545 | * @return string 546 | */ 547 | public function getUserAgent(): string 548 | { 549 | return 'DiscordBot (https://github.com/discord-php/DiscordPHP-HTTP, '.self::VERSION.')'; 550 | } 551 | } 552 | -------------------------------------------------------------------------------- /src/Discord/Multipart/MultipartBody.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Multipart; 13 | 14 | class MultipartBody 15 | { 16 | public const BOUNDARY = 'DISCORDPHP-HTTP-BOUNDARY'; 17 | 18 | private array $fields; 19 | public string $boundary; 20 | 21 | /** 22 | * @var MultipartField[] 23 | */ 24 | public function __construct(array $fields, ?string $boundary = null) 25 | { 26 | $this->fields = $fields; 27 | $this->boundary = $boundary ?? self::BOUNDARY; 28 | } 29 | 30 | public function __toString(): string 31 | { 32 | $prefixedBoundary = '--'.$this->boundary; 33 | $boundaryEnd = $prefixedBoundary.'--'; 34 | 35 | $convertedFields = array_map( 36 | function (MultipartField $field) { 37 | return (string) $field; 38 | }, 39 | $this->fields 40 | ); 41 | 42 | $fieldsString = implode(PHP_EOL.$prefixedBoundary.PHP_EOL, $convertedFields); 43 | 44 | return implode(PHP_EOL, [ 45 | $prefixedBoundary, 46 | $fieldsString, 47 | $boundaryEnd, 48 | ]); 49 | } 50 | 51 | public function getHeaders(): array 52 | { 53 | return [ 54 | 'Content-Type' => 'multipart/form-data; boundary='.$this->boundary, 55 | 'Content-Length' => strlen((string) $this), 56 | ]; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Discord/Multipart/MultipartField.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Multipart; 13 | 14 | class MultipartField 15 | { 16 | private string $name; 17 | private string $content; 18 | private array $headers; 19 | private ?string $fileName; 20 | 21 | /** 22 | * @var String[] 23 | */ 24 | public function __construct( 25 | string $name, 26 | string $content, 27 | array $headers = [], 28 | ?string $fileName = null 29 | ) { 30 | $this->name = $name; 31 | $this->content = $content; 32 | $this->headers = $headers; 33 | $this->fileName = $fileName; 34 | } 35 | 36 | public function __toString(): string 37 | { 38 | $out = 'Content-Disposition: form-data; name="'.$this->name.'"'; 39 | 40 | if (! is_null($this->fileName)) { 41 | $out .= '; filename="'.urlencode($this->fileName).'"'; 42 | } 43 | 44 | $out .= PHP_EOL; 45 | 46 | foreach ($this->headers as $header => $value) { 47 | $out .= $header.': '.$value.PHP_EOL; 48 | } 49 | 50 | $out .= PHP_EOL.$this->content.PHP_EOL; 51 | 52 | return $out; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Discord/RateLimit.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http; 13 | 14 | use RuntimeException; 15 | 16 | /** 17 | * Represents a rate-limit given by Discord. 18 | * 19 | * @author David Cole 20 | */ 21 | class RateLimit extends RuntimeException 22 | { 23 | /** 24 | * Whether the rate-limit is global. 25 | * 26 | * @var bool 27 | */ 28 | protected $global; 29 | 30 | /** 31 | * Time in seconds of when to retry after. 32 | * 33 | * @var float 34 | */ 35 | protected $retry_after; 36 | 37 | /** 38 | * Rate limit constructor. 39 | * 40 | * @param bool $global 41 | * @param float $retry_after 42 | */ 43 | public function __construct(bool $global, float $retry_after) 44 | { 45 | $this->global = $global; 46 | $this->retry_after = $retry_after; 47 | } 48 | 49 | /** 50 | * Gets the global parameter. 51 | * 52 | * @return bool 53 | */ 54 | public function isGlobal(): bool 55 | { 56 | return $this->global; 57 | } 58 | 59 | /** 60 | * Gets the retry after parameter. 61 | * 62 | * @return float 63 | */ 64 | public function getRetryAfter(): float 65 | { 66 | return $this->retry_after; 67 | } 68 | 69 | /** 70 | * Converts a rate-limit to a user-readable string. 71 | * 72 | * @return string 73 | */ 74 | public function __toString() 75 | { 76 | return 'RATELIMIT '.($this->global ? 'Global' : 'Non-global').', retry after '.$this->retry_after.' s'; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Discord/Request.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http; 13 | 14 | use React\Promise\Deferred; 15 | 16 | /** 17 | * Represents an HTTP request. 18 | * 19 | * @author David Cole 20 | */ 21 | class Request 22 | { 23 | /** 24 | * Deferred promise. 25 | * 26 | * @var Deferred 27 | */ 28 | protected $deferred; 29 | 30 | /** 31 | * Request method. 32 | * 33 | * @var string 34 | */ 35 | protected $method; 36 | 37 | /** 38 | * Request URL. 39 | * 40 | * @var Endpoint 41 | */ 42 | protected $url; 43 | 44 | /** 45 | * Request content. 46 | * 47 | * @var string 48 | */ 49 | protected $content; 50 | 51 | /** 52 | * Request headers. 53 | * 54 | * @var array 55 | */ 56 | protected $headers; 57 | 58 | /** 59 | * Request constructor. 60 | * 61 | * @param Deferred $deferred 62 | * @param string $method 63 | * @param Endpoint $url 64 | * @param string $content 65 | * @param array $headers 66 | */ 67 | public function __construct(Deferred $deferred, string $method, Endpoint $url, string $content, array $headers = []) 68 | { 69 | $this->deferred = $deferred; 70 | $this->method = $method; 71 | $this->url = $url; 72 | $this->content = $content; 73 | $this->headers = $headers; 74 | } 75 | 76 | /** 77 | * Gets the method. 78 | * 79 | * @return string 80 | */ 81 | public function getMethod(): string 82 | { 83 | return $this->method; 84 | } 85 | 86 | /** 87 | * Gets the url. 88 | * 89 | * @return string 90 | */ 91 | public function getUrl(): string 92 | { 93 | return Http::BASE_URL.'/'.$this->url; 94 | } 95 | 96 | /** 97 | * Gets the content. 98 | * 99 | * @return string 100 | */ 101 | public function getContent(): string 102 | { 103 | return $this->content; 104 | } 105 | 106 | /** 107 | * Gets the headers. 108 | * 109 | * @return string 110 | */ 111 | public function getHeaders(): array 112 | { 113 | return $this->headers; 114 | } 115 | 116 | /** 117 | * Returns the deferred promise. 118 | * 119 | * @return Deferred 120 | */ 121 | public function getDeferred(): Deferred 122 | { 123 | return $this->deferred; 124 | } 125 | 126 | /** 127 | * Returns the bucket ID for the request. 128 | * 129 | * @return string 130 | */ 131 | public function getBucketID(): string 132 | { 133 | return $this->method.$this->url->toAbsoluteEndpoint(true); 134 | } 135 | 136 | /** 137 | * Converts the request to a user-readable string. 138 | * 139 | * @return string 140 | */ 141 | public function __toString() 142 | { 143 | return 'REQ '.strtoupper($this->method).' '.$this->url; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /tests/Discord/DriverInterfaceTest.php: -------------------------------------------------------------------------------- 1 | shouldReceive([ 25 | 'getMethod' => $method, 26 | 'getUrl' => $url, 27 | 'getContent' => $content, 28 | 'getHeaders' => $headers, 29 | ]); 30 | 31 | return $request; 32 | } 33 | 34 | /** 35 | * @dataProvider requestProvider 36 | */ 37 | public function testRequest(string $method, string $url, array $content = [], array $verify = []) 38 | { 39 | $driver = $this->getDriver(); 40 | $request = $this->getRequest( 41 | $method, 42 | $url, 43 | $content === [] ? '' : json_encode($content), 44 | empty($content) ? [] : ['Content-Type' => 'application/json'] 45 | ); 46 | 47 | /** @var ResponseInterface */ 48 | $response = await($driver->runRequest($request)); 49 | 50 | $this->assertNotEquals('', $response->getBody()); 51 | $this->assertEquals(200, $response->getStatusCode()); 52 | 53 | $jsonDecodedBody = json_decode($response->getBody(), true); 54 | 55 | $verify['method'] = $method; 56 | 57 | foreach ($verify as $field => $expectedValue) { 58 | $this->assertEquals( 59 | $expectedValue, 60 | $jsonDecodedBody[$field] 61 | ); 62 | } 63 | } 64 | 65 | public function requestProvider(): array 66 | { 67 | $content = ['something' => 'value']; 68 | return [ 69 | 'Plain get' => [ 70 | 'method' => 'GET', 71 | 'url' => 'http://127.0.0.1:8888', 72 | ], 73 | 'Get with params' => [ 74 | 'method' => 'GET', 75 | 'url' => 'http://127.0.0.1:8888?something=value', 76 | 'verify' => [ 77 | 'args' => $content, 78 | ], 79 | ], 80 | 81 | 'Plain post' => [ 82 | 'method' => 'POST', 83 | 'url' => 'http://127.0.0.1:8888', 84 | ], 85 | 'Post with content' => [ 86 | 'method' => 'POST', 87 | 'url' => 'http://127.0.0.1:8888', 88 | 'content' => $content, 89 | 'verify' => [ 90 | 'json' => $content, 91 | ], 92 | ], 93 | 94 | 'Plain put' => [ 95 | 'method' => 'PUT', 96 | 'url' => 'http://127.0.0.1:8888', 97 | ], 98 | 'Put with content' => [ 99 | 'method' => 'PUT', 100 | 'url' => 'http://127.0.0.1:8888', 101 | 'content' => $content, 102 | 'verify' => [ 103 | 'json' => $content, 104 | ], 105 | ], 106 | 107 | 'Plain patch' => [ 108 | 'method' => 'PATCH', 109 | 'url' => 'http://127.0.0.1:8888', 110 | ], 111 | 'Patch with content' => [ 112 | 'method' => 'PATCH', 113 | 'url' => 'http://127.0.0.1:8888', 114 | 'content' => $content, 115 | 'verify' => [ 116 | 'json' => $content, 117 | ], 118 | ], 119 | 120 | 'Plain delete' => [ 121 | 'method' => 'DELETE', 122 | 'url' => 'http://127.0.0.1:8888', 123 | ], 124 | 'Delete with content' => [ 125 | 'method' => 'DELETE', 126 | 'url' => 'http://127.0.0.1:8888', 127 | 'content' => $content, 128 | 'verify' => [ 129 | 'json' => $content, 130 | ], 131 | ], 132 | ]; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /tests/Discord/EndpointTest.php: -------------------------------------------------------------------------------- 1 | bindArgs(...$replacements); 17 | 18 | $this->assertEquals( 19 | $endpoint->toAbsoluteEndpoint(true), 20 | $expected 21 | ); 22 | } 23 | 24 | public function majorParamProvider(): array 25 | { 26 | return [ 27 | 'Several major params' => [ 28 | 'uri' => 'something/:guild_id/:channel_id/:webhook_id', 29 | 'replacements' => ['::guild id::', '::channel id::', '::webhook id::'], 30 | 'expected' => 'something/::guild id::/::channel id::/::webhook id::', 31 | ], 32 | 'Single major param' => [ 33 | 'uri' => 'something/:guild_id', 34 | 'replacements' => ['::guild id::'], 35 | 'expected' => 'something/::guild id::', 36 | ], 37 | 'Single major param, some minor params' => [ 38 | 'uri' => 'something/:guild_id/:some_param/:something_else', 39 | 'replacements' => ['::guild id::', '::some_param::', '::something else::'], 40 | 'expected' => 'something/::guild id::/:some_param/:something_else', 41 | ], 42 | 'Only minor params' => [ 43 | 'uri' => 'something/:something/:some_param/:something_else', 44 | 'replacements' => ['::something::', '::some_param::', '::something else::'], 45 | 'expected' => 'something/:something/:some_param/:something_else', 46 | ], 47 | 'Minor and major params in weird order' => [ 48 | 'uri' => 'something/:something/:guild_id/:something_else/:channel_id', 49 | 'replacements' => ['::something::', '::guild id::', '::something else::', '::channel id::'], 50 | 'expected' => 'something/:something/::guild id::/:something_else/::channel id::', 51 | ], 52 | ]; 53 | } 54 | 55 | /** 56 | * @dataProvider allParamProvider 57 | */ 58 | public function testBindAllParams(string $uri, array $replacements, string $expected) 59 | { 60 | $endpoint = new Endpoint($uri); 61 | $endpoint->bindArgs(...$replacements); 62 | 63 | $this->assertEquals( 64 | $expected, 65 | $endpoint->toAbsoluteEndpoint() 66 | ); 67 | } 68 | 69 | public function allParamProvider(): array 70 | { 71 | return [ 72 | 'Several major params' => [ 73 | 'uri' => 'something/:guild_id/:channel_id/:webhook_id', 74 | 'replacements' => ['::guild id::', '::channel id::', '::webhook id::'], 75 | 'expected' => 'something/::guild id::/::channel id::/::webhook id::', 76 | ], 77 | 'Single major param' => [ 78 | 'uri' => 'something/:guild_id', 79 | 'replacements' => ['::guild id::'], 80 | 'expected' => 'something/::guild id::', 81 | ], 82 | 'Single major param, some minor params' => [ 83 | 'uri' => 'something/:guild_id/:some_param/:something_else', 84 | 'replacements' => ['::guild id::', '::some param::', '::something else::'], 85 | 'expected' => 'something/::guild id::/::some param::/::something else::', 86 | ], 87 | 'Only minor params' => [ 88 | 'uri' => 'something/:something/:some_param/:other', 89 | 'replacements' => ['::something::', '::some param::', '::something else::'], 90 | 'expected' => 'something/::something::/::some param::/::something else::', 91 | ], 92 | 'Minor and major params in weird order' => [ 93 | 'uri' => 'something/:something/:guild_id/:other/:channel_id', 94 | 'replacements' => ['::something::', '::guild id::', '::something else::', '::channel id::'], 95 | 'expected' => 'something/::something::/::guild id::/::something else::/::channel id::', 96 | ], 97 | 98 | // @see https://github.com/discord-php/DiscordPHP-Http/issues/16 99 | // 'Params with same prefix, short first' => [ 100 | // 'uri' => 'something/:thing/:thing_other', 101 | // 'replacements' => ['::thing::', '::thing other::'], 102 | // 'expected' => 'something/::thing::/::thing other::', 103 | // ], 104 | // 'Params with same prefix, short first' => [ 105 | // 'uri' => 'something/:thing_other/:thing', 106 | // 'replacements' => ['::thing other::', '::thing::'], 107 | // 'expected' => 'something/::thing other::/::thing::', 108 | // ], 109 | ]; 110 | } 111 | 112 | public function testBindAssoc() 113 | { 114 | $endpoint = new Endpoint('something/:first/:second'); 115 | $endpoint->bindAssoc([ 116 | 'second' => '::second::', 117 | 'first' => '::first::', 118 | ]); 119 | 120 | $this->assertEquals( 121 | 'something/::first::/::second::', 122 | $endpoint->toAbsoluteEndpoint() 123 | ); 124 | } 125 | 126 | public function testItConvertsToString() 127 | { 128 | $this->assertEquals( 129 | 'something/::first::/::second::', 130 | (string) Endpoint::bind( 131 | 'something/:first/:second', 132 | '::first::', 133 | '::second::' 134 | ) 135 | ); 136 | } 137 | 138 | public function itCanAddQueryParams() 139 | { 140 | $endpoint = new Endpoint('something/:param'); 141 | $endpoint->bindArgs('param'); 142 | 143 | $endpoint->addQuery('something', 'value'); 144 | $endpoint->addQuery('boolval', true); 145 | 146 | $this->assertEquals( 147 | 'something/param?something=value&boolval=1', 148 | $endpoint->toAbsoluteEndpoint() 149 | ); 150 | } 151 | 152 | public function itDoesNotAddQueryParamsForMajorParameters() 153 | { 154 | $endpoint = new Endpoint('something/:guild_id'); 155 | $endpoint->bindArgs('param'); 156 | 157 | $endpoint->addQuery('something', 'value'); 158 | $endpoint->addQuery('boolval', true); 159 | 160 | $this->assertEquals( 161 | 'something/param', 162 | $endpoint->toAbsoluteEndpoint(true) 163 | ); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /tests/Discord/Multipart/MultipartTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Tests\Discord\Http\Multipart; 13 | 14 | use Discord\Http\Multipart\MultipartBody; 15 | use Discord\Http\Multipart\MultipartField; 16 | use Mockery; 17 | use PHPUnit\Framework\TestCase; 18 | 19 | class MultipartTest extends TestCase 20 | { 21 | /** 22 | * @dataProvider multipartFieldStringConversionProvider 23 | */ 24 | public function testMultipartFieldStringConversion(array $constructorArgs, string $expected) 25 | { 26 | $multipartField = new MultipartField(...$constructorArgs); 27 | 28 | $this->assertEquals($expected, (string) $multipartField); 29 | } 30 | 31 | public function multipartFieldStringConversionProvider(): array 32 | { 33 | return [ 34 | 'Completely filled' => [ 35 | 'args' => [ 36 | '::name::', 37 | '::content::', 38 | [ 39 | 'Header-Name' => 'Value', 40 | ], 41 | '::filename::', 42 | ], 43 | 44 | 'expected' => << [ 53 | 'args' => [ 54 | '::name::', 55 | '::content::', 56 | [ 57 | 'Header-Name' => 'Value', 58 | ], 59 | null, 60 | ], 61 | 62 | 'expected' => << [ 71 | 'args' => [ 72 | '::name::', 73 | '::content::', 74 | [], 75 | '::filename::', 76 | ], 77 | 78 | 'expected' => <<shouldReceive('__toString')->andReturn($return); 93 | 94 | return $mock; 95 | }, ['::first field::', '::second field::', '::third field::']); 96 | 97 | $multipartBody = new MultipartBody($fields, '::boundary::'); 98 | 99 | $this->assertEquals( 100 | <<assertEquals([ 113 | 'Content-Type' => 'multipart/form-data; boundary=::boundary::', 114 | 'Content-Length' => strlen((string) $multipartBody), 115 | ], $multipartBody->getHeaders()); 116 | } 117 | 118 | public function testGeneratingBoundary() 119 | { 120 | $multipartBody = new MultipartBody([ 121 | Mockery::mock(MultipartField::class), 122 | ]); 123 | 124 | $this->assertNotNull($multipartBody->boundary); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /tests/Discord/RequestTest.php: -------------------------------------------------------------------------------- 1 | getRequest($deferred); 37 | 38 | $this->assertEquals($deferred, $request->getDeferred()); 39 | } 40 | 41 | public function testGetMethod() 42 | { 43 | $request = $this->getRequest(null, '::method::'); 44 | 45 | $this->assertEquals('::method::', $request->getMethod()); 46 | } 47 | 48 | public function testGetUrl() 49 | { 50 | $request = $this->getRequest(null, '', new Endpoint('::url::')); 51 | 52 | $this->assertEquals(Http::BASE_URL . '/::url::', $request->getUrl()); 53 | } 54 | 55 | public function testGetContent() 56 | { 57 | $request = $this->getRequest(null, '', null, '::content::'); 58 | 59 | $this->assertEquals('::content::', $request->getContent()); 60 | } 61 | 62 | public function testGetHeaders() 63 | { 64 | $request = $this->getRequest(null, '', null, '::content::', ['something' => 'value']); 65 | 66 | $this->assertEquals(['something' => 'value'], $request->getHeaders()); 67 | } 68 | 69 | public function testGetBucketId() 70 | { 71 | $endpoint = Mockery::mock(Endpoint::class); 72 | $endpoint->shouldReceive('toAbsoluteEndpoint')->andReturn('::endpoint::'); 73 | 74 | $request = $this->getRequest(null, '::method::', $endpoint); 75 | 76 | $this->assertEquals('::method::::endpoint::', $request->getBucketID()); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tests/Drivers/GuzzleTest.php: -------------------------------------------------------------------------------- 1 | $request->getMethod(), 12 | 'args' => $request->getQueryParams(), 13 | 'json' => $request->getHeader('Content-Type') === ['application/json'] 14 | ? json_decode($request->getBody()) 15 | : [] 16 | ]; 17 | 18 | return Response::json($response); 19 | }); 20 | 21 | $socket = new SocketServer('127.0.0.1:8888'); 22 | 23 | $http->listen($socket); 24 | --------------------------------------------------------------------------------