├── .gitignore ├── docs ├── uploads │ ├── download.png │ └── result-of-breakdown-example.png ├── send-request.md ├── send-multiple-requests.md └── breakdown-large-request.md ├── tests ├── AgentGeneratorTest.php ├── LoopTest.php ├── Creative │ └── Axios.html ├── HttpRequestTest.php └── WebSocketClientTest.php ├── phpunit.xml ├── src ├── Exceptions │ ├── BadUriException.php │ ├── BadOpcodeException.php │ ├── ConnectionException.php │ ├── BadResponseException.php │ ├── InvalidArgumentException.php │ └── WebSocketException.php ├── Contracts │ ├── MessageContract.php │ ├── LiveEventContract.php │ ├── ConnectionContract.php │ ├── WebSocketContract.php │ ├── WscCommonsContract.php │ └── CommonsContract.php ├── SocketClient.php ├── Loop.php ├── FormData.php ├── Model │ ├── ProxyServer.php │ ├── HttpResponse.php │ ├── DownloadResult.php │ └── HttpOptions.php ├── Traits │ ├── HttpClientFace.php │ ├── WSConnectionTrait.php │ └── WSClientTrait.php ├── Chunk.php ├── Enums │ ├── ErrorCode.php │ └── CurlInfo.php ├── Utils │ └── Toolkit.php ├── HttpClient.php ├── Middleware.php ├── Helpers │ └── Functions.php ├── WebSocketConfig.php ├── WebSocket.php └── AgentGenerator.php ├── examples ├── upload │ ├── upload-multiple-files.php │ ├── josh_sean.php │ └── send-file-to-telegram.php ├── download │ └── download-large-file.php ├── websocket │ ├── anonymous-callable.php │ └── coinmarketcap-ticker.php └── bulk-request │ ├── send-multiple-requests.php │ └── breakdown-large-request.php ├── .github └── workflows │ └── test.yml ├── LICENSE ├── composer.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | /.env 3 | /vendor 4 | /*.test.php 5 | /.phpunit.result.cache -------------------------------------------------------------------------------- /docs/uploads/download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shahradelahi/easy-http/master/docs/uploads/download.png -------------------------------------------------------------------------------- /docs/uploads/result-of-breakdown-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shahradelahi/easy-http/master/docs/uploads/result-of-breakdown-example.png -------------------------------------------------------------------------------- /tests/AgentGeneratorTest.php: -------------------------------------------------------------------------------- 1 | assertIsString((new \EasyHttp\AgentGenerator)->generate()); 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ./tests/ 5 | 6 | 7 | 8 | 9 | ./src/ 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/Exceptions/BadUriException.php: -------------------------------------------------------------------------------- 1 | getcwd() . '/../docs/uploads/result-of-breakdown-example.png', 9 | 'photo2' => getcwd() . '/../docs/uploads/download.png', 10 | ]); 11 | 12 | echo '
' . \EasyHttp\Utils\Toolkit::prettyJson($result->getBody()) . '
'; 13 | -------------------------------------------------------------------------------- /examples/download/download-large-file.php: -------------------------------------------------------------------------------- 1 | ' . json_encode($Result->chunks, JSON_PRETTY_PRINT) . ''; 11 | 12 | 13 | if ($Result->save(__DIR__ . '/uploads/google.png')) { 14 | echo 'File saved successfully'; 15 | } else { 16 | echo 'File not saved'; 17 | } -------------------------------------------------------------------------------- /tests/LoopTest.php: -------------------------------------------------------------------------------- 1 | $end_time) { 20 | Loop::stop(); 21 | } 22 | }, 100); 23 | 24 | $this->assertCount(10, $runs); 25 | } 26 | 27 | } -------------------------------------------------------------------------------- /src/Contracts/MessageContract.php: -------------------------------------------------------------------------------- 1 | getFile() . ' ' . $this->getLine() . ' ' . $this->getMessage() . PHP_EOL; 18 | echo $this->getTraceAsString(); 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /examples/upload/josh_sean.php: -------------------------------------------------------------------------------- 1 | 1) { 8 | for ($i = 1; $i < $c; $i++) { 9 | $s[$out[1][$i]] = $out[1][0]; 10 | } 11 | } 12 | } 13 | return $s; 14 | } 15 | 16 | $data = generateUpToDateMimeArray('https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types'); 17 | echo '
' . json_encode($data, JSON_PRETTY_PRINT) . '
'; -------------------------------------------------------------------------------- /src/Contracts/LiveEventContract.php: -------------------------------------------------------------------------------- 1 | on('open', function (\EasyHttp\WebSocket $socket) { 7 | echo "Connected to server
"; 8 | $socket->send("Hello World"); 9 | }); 10 | 11 | $SocketClient->on('message', function (\EasyHttp\WebSocket $socket, $message) { 12 | echo $message . "
"; 13 | $socket->close(); 14 | }); 15 | 16 | $SocketClient->on('close', function (\EasyHttp\WebSocket $socket, int $closeStatus) { 17 | echo "Disconnected with status: $closeStatus
"; 18 | }); 19 | 20 | $SocketClient->connect('wss://socket.litehex.com/', new \EasyHttp\WebSocketConfig()); 21 | -------------------------------------------------------------------------------- /src/Contracts/ConnectionContract.php: -------------------------------------------------------------------------------- 1 | post('https://api.telegram.org/bot' . $your_token . '/sendPhoto', [ 11 | 'query' => [ 12 | 'chat_id' => $chat_id, 13 | 'caption' => 'Привет!', 14 | ], 15 | 'multipart' => \EasyHttp\FormData::create([ 16 | 'photo' => getcwd() . '/../docs/uploads/download.png' 17 | ]) 18 | ]); 19 | 20 | 21 | echo '
' . \EasyHttp\Utils\Toolkit::prettyJson($response->getBody()) . '
'; -------------------------------------------------------------------------------- /tests/Creative/Axios.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Axios | Upload file 4 | 5 | 6 | 7 |
8 |
9 | 10 | 11 |
12 | 13 | 28 | -------------------------------------------------------------------------------- /src/SocketClient.php: -------------------------------------------------------------------------------- 1 | send(time() . '~PONG'); 22 | } 23 | 24 | public function onPong(WebSocket $socket): void 25 | { 26 | $socket->send(time() . '~PING'); 27 | } 28 | 29 | public function onMeantime(WebSocket $socket): void 30 | { 31 | return; 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /docs/send-request.md: -------------------------------------------------------------------------------- 1 | ```php 2 | require_once $_SERVER['DOCUMENT_ROOT'] . '/vendor/autoload.php'; 3 | $EZClient = new \EasyHttp\HttpClient(); 4 | ``` 5 | 6 | ```php 7 | $Response = $EZClient->post('https://httpbin.org/post', [ 8 | 'headers' => [ 9 | 'Content-Type' => 'application/json' 10 | ], 11 | 'body' => [ 12 | 'name' => 'John Doe', 13 | 'age' => '25' 14 | ] 15 | ]); 16 | 17 | echo '
' . $Response->getBody() . '
'; 18 | ``` 19 | 20 | ```php 21 | $Response = $EZClient->get('https://httpbin.org/get', [ 22 | 'headers' => [ 23 | 'Accept' => 'application/json', 24 | 'User-Agent' => 'EasyHttp/1.0.0' 25 | ] 26 | ]); 27 | 28 | echo '
' . $Response->getBody() . '
'; 29 | ``` 30 | 31 | ```php 32 | $Response = $EZClient->get('https://httpbin.org/get', [ 33 | 'query' => [ 34 | 'name' => 'John Doe', 35 | 'age' => '25' 36 | ] 37 | ]); 38 | 39 | echo '
' . $Response->getBody() . '
'; 40 | ``` -------------------------------------------------------------------------------- /docs/send-multiple-requests.md: -------------------------------------------------------------------------------- 1 | # Send Multiple Requests at once 2 | 3 | The source code of this documentation is available 4 | at [`send-multiple-requests.php`](../examples/bulk-request/send-multiple-requests.php). 5 | 6 | ### Getting Started 7 | 8 | In order to send multiple requests at once, you need to create a `Client` instance. 9 | 10 | ```php 11 | $client = new \EasyHttp\HttpClient(); 12 | ``` 13 | 14 | Create your requests: 15 | 16 | ```php 17 | $requests = [ 18 | [ 19 | 'method' => 'GET', 20 | 'url' => 'https://httpbin.org/get', 21 | ], 22 | [ 23 | 'method' => 'GET', 24 | 'url' => 'https://httpbin.org/get', 25 | ], 26 | [ 27 | 'method' => 'GET', 28 | 'url' => 'https://httpbin.org/get', 29 | ], 30 | ]; 31 | ``` 32 | 33 | After you have created the requests, you can send them all at once by using the `bulk` method. 34 | 35 | ```php 36 | $responses = $client->bulk($requests); 37 | foreach ($responses as $response) { 38 | echo $response->getBody() . PHP_EOL; // outputs the response body 39 | } 40 | ``` -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: PHPUnit Test 2 | 3 | on: 4 | push: 5 | branches: [ "master", "develop" ] 6 | pull_request: 7 | branches: [ "master", "develop" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Validate composer.json and composer.lock 21 | run: composer validate --strict 22 | 23 | - name: Cache Composer packages 24 | id: composer-cache 25 | uses: actions/cache@v3 26 | with: 27 | path: vendor 28 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 29 | restore-keys: | 30 | ${{ runner.os }}-php- 31 | 32 | - name: Create Environments 33 | run: | 34 | touch .env 35 | echo "${{ secrets.ENVIRONMENT_CONTENT }}" > .env 36 | 37 | - name: Install and update Composer packages 38 | run: composer install --prefer-dist --no-progress 39 | 40 | - name: Run test suite 41 | run: composer run-script phpunit-test -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Shahrad Elahi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Contracts/WebSocketContract.php: -------------------------------------------------------------------------------- 1 | $interval) { 35 | $callback(); 36 | $last_hit = Toolkit::time(); 37 | } 38 | } 39 | } 40 | 41 | /** 42 | * @return void 43 | */ 44 | public static function stop(): void 45 | { 46 | static::$running = false; 47 | } 48 | 49 | } -------------------------------------------------------------------------------- /examples/bulk-request/send-multiple-requests.php: -------------------------------------------------------------------------------- 1 | 'https://httpbin.org/get', 9 | ], 10 | [ 11 | 'method' => 'GET', 12 | 'uri' => 'https://httpbin.org/get', 13 | 'options' => [ 14 | 'query' => [ 15 | 'foo' => 'bar', 16 | ], 17 | ], 18 | ], 19 | [ 20 | 'method' => 'POST', 21 | 'uri' => 'https://httpbin.org/post', 22 | 'options' => [ 23 | 'headers' => [ 24 | 'User-Agent' => 'EasyHttp/1.0.0', 25 | ], 26 | 'body' => [ 27 | 'foo' => 'bar', 28 | ] 29 | ], 30 | ], 31 | [ 32 | 'uri' => 'https://httpbin.org/post', 33 | 'options' => [ 34 | 'body' => "Hello World", 35 | ], 36 | ] 37 | ]; 38 | 39 | foreach ((new \EasyHttp\HttpClient())->bulk($requests) as $response) { 40 | if ($response->getStatusCode() == 200) { 41 | echo '
' . $response->getBody() . '
'; 42 | } else { 43 | echo '
Error: ' . $response->getError() . '
'; 44 | } 45 | } -------------------------------------------------------------------------------- /tests/HttpRequestTest.php: -------------------------------------------------------------------------------- 1 | post('https://httpbin.org/post', [ 13 | 'headers' => [ 14 | 'Content-Type' => 'application/json' 15 | ], 16 | 'body' => [ 17 | 'name' => 'John Doe', 18 | 'age' => '25' 19 | ] 20 | ]); 21 | 22 | $this->assertEquals(200, $response->getStatusCode()); 23 | $this->assertEquals('application/json', $response->getHeaderLine('Content-Type')); 24 | $this->assertEquals( 25 | json_decode('{"name":"John Doe","age":"25"}', true), 26 | json_decode($response->getBody(), true)['json'] 27 | ); 28 | } 29 | 30 | public function testGetRequest(): void 31 | { 32 | $response = (new HttpClient())->get('https://httpbin.org/get', [ 33 | 'headers' => [ 34 | 'User-Agent' => 'EasyHttp/v1.1.2 (PHP: ' . PHP_VERSION . ')', 35 | 'Accept' => 'application/json' 36 | ], 37 | 'query' => [ 38 | 'customer' => 'John Doe' 39 | ] 40 | ]); 41 | 42 | $body = json_decode($response->getBody(), true); 43 | 44 | $this->assertEquals(200, $response->getStatusCode()); 45 | $this->assertEquals('EasyHttp/v1.1.2 (PHP: ' . PHP_VERSION . ')', $body['headers']['User-Agent']); 46 | $this->assertEquals('application/json', $response->getHeaderLine('Content-Type')); 47 | $this->assertEquals('John Doe', $body['args']['customer']); 48 | } 49 | 50 | } -------------------------------------------------------------------------------- /src/FormData.php: -------------------------------------------------------------------------------- 1 | $path) { 32 | $this->addFile((string)$name, $path); 33 | } 34 | } 35 | 36 | /** 37 | * @param string $name 38 | * @param string|\CURLFile $file 39 | * @return $this 40 | */ 41 | public function addFile(string $name, string|\CURLFile $file): FormData 42 | { 43 | if ($file instanceof \CURLFile) { 44 | $this->files[$name] = $file; 45 | return $this; 46 | } 47 | 48 | $this->files[$name] = new \CURLFile( 49 | realpath($file), 50 | getFileMime($file), 51 | basename($file) 52 | ); 53 | 54 | return $this; 55 | } 56 | 57 | /** 58 | * This method will create an array with instances of CURLFile class 59 | * 60 | * @param string|array $file_path 61 | * @return array 62 | */ 63 | public static function create(string|array $file_path): array 64 | { 65 | return (new FormData($file_path))->getFiles(); 66 | } 67 | 68 | /** 69 | * Get the field has passed through the class 70 | * 71 | * @return array 72 | */ 73 | public function getFiles(): array 74 | { 75 | return $this->files ?? []; 76 | } 77 | 78 | } -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shahradelahi/easy-http", 3 | "description": "An easy to use HTTP/WebSocket client for PHP", 4 | "type": "library", 5 | "license": "MIT", 6 | "keywords": [ 7 | "http", 8 | "client", 9 | "curl", 10 | "websoket", 11 | "php" 12 | ], 13 | "homepage": "https://github.com/shahradelahi/easy-http", 14 | "support": { 15 | "issues": "https://github.com/shahradelahi/easy-http/issues", 16 | "source": "https://github.com/shahradelahi/easy-http" 17 | }, 18 | "authors": [ 19 | { 20 | "name": "Shahrad Elahi", 21 | "email": "shahrad@litehex.com", 22 | "role": "developer" 23 | }, 24 | { 25 | "name": "Arthur Kushman", 26 | "email": "arthurkushman@gmail.com", 27 | "role": "advisor" 28 | } 29 | ], 30 | "scripts": { 31 | "phpunit-test": "vendor/bin/phpunit --colors=always --configuration phpunit.xml" 32 | }, 33 | "minimum-stability": "dev", 34 | "prefer-stable": true, 35 | "require": { 36 | "php": ">=8.0", 37 | "ext-json": "*", 38 | "ext-curl": "*", 39 | "ext-pcntl": "*", 40 | "symfony/mime": "^v6.1.5", 41 | "utilities-php/common": "dev-master" 42 | }, 43 | "require-dev": { 44 | "phpunit/phpunit": "^9.5.20", 45 | "fakerphp/faker": "~v1.20.0" 46 | }, 47 | "suggest": { 48 | "ext-pcntl": "Required for creating a websocket Server/Client (*)", 49 | "ext-curl": "Required for processing HTTP Requests (*)", 50 | "ext-json": "Required for parsing Json (*)", 51 | "symfony/mime": "Required for detecting mime types (~6.1.5)", 52 | "fakerphp/faker": "Required for generating fake data for testing (~1.20.0)" 53 | }, 54 | "autoload": { 55 | "psr-4": { 56 | "EasyHttp\\": "src/" 57 | }, 58 | "files": [ 59 | "src/Helpers/Functions.php" 60 | ] 61 | }, 62 | "autoload-dev": { 63 | "psr-4": { 64 | "EasyHttp\\Tests\\": "tests/" 65 | } 66 | }, 67 | "config": { 68 | "sort-packages": true 69 | } 70 | } -------------------------------------------------------------------------------- /examples/websocket/coinmarketcap-ticker.php: -------------------------------------------------------------------------------- 1 | on('meantime', function (WebSocket $socket) use ($close_time) { 12 | if (time() >= $close_time) { 13 | $socket->close(); 14 | } 15 | }); 16 | 17 | $SocketClient->on('open', function (WebSocket $socket) { 18 | echo sprintf( 19 | '
%s: Connected to %s

', 20 | date('Y-m-d H:i:s'), 21 | $socket->getSocketUrl() 22 | ); 23 | 24 | $socket->send(json_encode([ 25 | 'method' => 'subscribe', 26 | 'id' => 'price', 27 | 'data' => [ 28 | 'cryptoIds' => [1, 1027, 825, 3408, 1839, 4687, 52, 2010, 5426], 29 | 'index' => 'detail' 30 | ] 31 | ])); 32 | }); 33 | 34 | $SocketClient->on('close', function (WebSocket $socket, int $closeStatus) { 35 | echo sprintf( 36 | '
%s: Disconnected with status: %s

', 37 | date('Y-m-d H:i:s'), 38 | $closeStatus 39 | ); 40 | }); 41 | 42 | $SocketClient->on('message', function (WebSocket $socket, string $message) { 43 | $data = json_decode($message, true); 44 | if (isset($data['id']) && $data['id'] == "price") { 45 | echo sprintf( 46 | '
%s: %s

', 47 | date('Y-m-d H:i:s'), 48 | $message 49 | ); 50 | } 51 | }); 52 | 53 | $SocketClient->on('error', function (WebSocket $socket, WebSocketException $exception) { 54 | echo sprintf( 55 | "
%s: Error: %s
File: %s:%s

", 56 | date('Y-m-d H:i:s'), 57 | $exception->getMessage(), 58 | $exception->getFile(), 59 | $exception->getLine() 60 | ); 61 | }); 62 | 63 | $SocketClient->connect('wss://stream.coinmarketcap.com/price/latest', (new WebSocketConfig()) 64 | ->setFragmentSize(8096) 65 | ->setTimeout(15) 66 | ); -------------------------------------------------------------------------------- /src/Contracts/CommonsContract.php: -------------------------------------------------------------------------------- 1 | 'onMessage', 25 | self::EVENT_TYPE_PING => 'onPing', 26 | self::EVENT_TYPE_PONG => 'onPong', 27 | ]; 28 | 29 | // DECODE FRAMES 30 | public const DECODE_TEXT = 1; 31 | public const DECODE_BINARY = 2; 32 | public const DECODE_CLOSE = 8; 33 | public const DECODE_PING = 9; 34 | public const DECODE_PONG = 10; 35 | 36 | // ENCODE FRAMES 37 | public const ENCODE_TEXT = 129; 38 | public const ENCODE_CLOSE = 136; 39 | public const ENCODE_PING = 137; 40 | public const ENCODE_PONG = 138; 41 | 42 | // MASKS 43 | public const MASK_125 = 125; 44 | public const MASK_126 = 126; 45 | public const MASK_127 = 127; 46 | public const MASK_128 = 128; 47 | public const MASK_254 = 254; 48 | public const MASK_255 = 255; 49 | 50 | // PAYLOADS 51 | public const PAYLOAD_CHUNK = 8; 52 | public const PAYLOAD_MAX_BITS = 65535; 53 | 54 | // transfer protocol-level errors 55 | public const SERVER_COULD_NOT_BIND_TO_SOCKET = 101; 56 | public const SERVER_SELECT_ERROR = 102; 57 | public const SERVER_HEADERS_NOT_SET = 103; 58 | public const CLIENT_COULD_NOT_OPEN_SOCKET = 104; 59 | public const CLIENT_INCORRECT_SCHEME = 105; 60 | public const CLIENT_INVALID_UPGRADE_RESPONSE = 106; 61 | public const CLIENT_INVALID_STREAM_CONTEXT = 107; 62 | public const CLIENT_BAD_OPCODE = 108; 63 | public const CLIENT_COULD_ONLY_WRITE_LESS = 109; 64 | public const CLIENT_BROKEN_FRAME = 110; 65 | public const CLIENT_EMPTY_READ = 111; 66 | public const SERVER_INVALID_STREAM_CONTEXT = 112; 67 | public const CLIENT_CONNECTION_NOT_ESTABLISHED = 113; 68 | public const CLIENT_EVENT_NOT_CALLABLE = 114; 69 | 70 | } -------------------------------------------------------------------------------- /src/Model/ProxyServer.php: -------------------------------------------------------------------------------- 1 | ip = $proxy['ip']; 59 | $this->port = $proxy['port']; 60 | $this->username = $proxy['user']; 61 | $this->password = $proxy['pass']; 62 | 63 | return $this; 64 | } 65 | 66 | /** 67 | * Set Proxy Server Type 68 | * 69 | * @param int $type [CURLPROXY_SOCKS5|CURLPROXY_SOCKS4|CURLPROXY_HTTP] 70 | * @return ProxyServer 71 | */ 72 | public function setType(int $type): self 73 | { 74 | if (!in_array($type, [CURLPROXY_SOCKS5, CURLPROXY_SOCKS4, CURLPROXY_HTTP])) { 75 | throw new \InvalidArgumentException('Invalid Proxy Type'); 76 | } 77 | $this->type = $type; 78 | 79 | return $this; 80 | } 81 | 82 | /** 83 | * Get the IP:Port string 84 | * 85 | * @return ?string 86 | */ 87 | public function getHost(): ?string 88 | { 89 | return !empty($this->ip) && !empty($this->port) ? "$this->ip:$this->port" : null; 90 | } 91 | 92 | /** 93 | * Get auth data 94 | * 95 | * @return ?string 96 | */ 97 | public function getAuth(): ?string 98 | { 99 | return !empty($this->username) && !empty($this->password) ? "$this->username:$this->password" : null; 100 | } 101 | 102 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > **Help wanted:** If you can improve this library, please do so. 2 | > ***Pull requests are welcome.*** 3 | 4 | # Easy Http 5 | 6 | [![Build Status](https://scrutinizer-ci.com/g/shahradelahi/easy-http/badges/build.png?b=master)](https://scrutinizer-ci.com/g/shahradelahi/easy-http/build-status/master) 7 | [![Coverage Status](https://scrutinizer-ci.com/g/shahradelahi/easy-http/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/shahradelahi/easy-http/?branch=master) 8 | [![Code Quality](https://img.shields.io/scrutinizer/g/shahradelahi/easy-http/master.svg?style=flat)](https://scrutinizer-ci.com/g/shahradelahi/easy-http/?b=master) 9 | [![Latest Stable Version](https://img.shields.io/packagist/v/shahradelahi/easy-http.svg)](https://packagist.org/packages/shahradelahi/easy-http) 10 | [![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D8.0-8892BF.svg)](https://php.net/) 11 | [![License](https://img.shields.io/packagist/l/shahradelahi/easy-http.svg)](https://github.com/shahradelahi/easy-http/LICENSE) 12 | 13 | EasyHttp is a lightweight HTTP client that is easy to use and integrates with your existing PHP application. 14 | 15 | * Simple interface for building query strings, headers, and body. 16 | * Supports all HTTP methods, and supports streaming of large files. 17 | * **No dependency**, no need to install any third-party libraries. 18 | * Supports multiple/bulk requests and downloads large files. 19 | * And much more! 20 | 21 | #### Installation 22 | 23 | ```sh 24 | composer require shahradelahi/easy-http 25 | ``` 26 | 27 |
28 | Click for help with installation 29 | 30 | ## Install Composer 31 | 32 | If the above step didn't work, install composer and try again. 33 | 34 | #### Debian / Ubuntu 35 | 36 | ``` 37 | sudo apt-get install curl php-curl 38 | curl -s https://getcomposer.org/installer | php 39 | php composer.phar install 40 | ``` 41 | 42 | Composer not found? Use this command instead: 43 | 44 | ``` 45 | php composer.phar require "shahradelahi/easy-http" 46 | ``` 47 | 48 | #### Windows: 49 | 50 | [Download installer for Windows](https://getcomposer.org/doc/00-intro.md#installation-windows) 51 | 52 |
53 | 54 | #### Getting started 55 | 56 | ```php 57 | $client = new \EasyHttp\HttpClient(); 58 | $response = $client->get('https://httpbin.org/get'); 59 | 60 | echo $response->getStatusCode(); // 200 61 | echo $response->getHeaderLine('content-type'); // 'application/json' 62 | echo $response->getBody(); // {"args":{},"headers":{},"origin":"**", ...} 63 | ``` 64 | 65 | ========= 66 | 67 | ### Documentation 68 | 69 | We've created some sample of usage in below and if you have questions or want a new feature, please feel free to 70 | open [an issue](https://github.com/shahradelahi/easy-http/issues/new). 71 | 72 | * [Send simple request](/docs/send-request.md) 73 | * [Breakdown of a large request into pieces](/docs/breakdown-large-request.md) 74 | * [Send multiple requests at once](/docs/send-multiple-requests.md) 75 | * [Download large files](/examples/download/download-large-file.php) 76 | * [Upload multiple files](/examples/upload/upload-multiple-files.php) 77 | 78 | ### License 79 | 80 | EasyHttp is licensed under the MIT License - see the [LICENSE](/LICENSE) file for details -------------------------------------------------------------------------------- /src/Traits/HttpClientFace.php: -------------------------------------------------------------------------------- 1 | request('GET', $uri, $options); 40 | } 41 | 42 | /** 43 | * Create and send an HTTP HEAD request. 44 | * 45 | * @param string $uri URI object or string. 46 | * @param HttpOptions|array $options Request options to apply. 47 | * 48 | * @return HttpResponse 49 | */ 50 | public function head(string $uri, HttpOptions|array $options = []): HttpResponse 51 | { 52 | return $this->request('HEAD', $uri, $options); 53 | } 54 | 55 | /** 56 | * Create and send an HTTP PUT request. 57 | * 58 | * @param string $uri URI object or string. 59 | * @param HttpOptions|array $options Request options to apply. 60 | * 61 | * @return HttpResponse 62 | */ 63 | public function put(string $uri, HttpOptions|array $options = []): HttpResponse 64 | { 65 | return $this->request('PUT', $uri, $options); 66 | } 67 | 68 | /** 69 | * Create and send an HTTP POST request. 70 | * 71 | * @param string $uri URI object or string. 72 | * @param HttpOptions|array $options Request options to apply. 73 | * 74 | * @return HttpResponse 75 | */ 76 | public function post(string $uri, HttpOptions|array $options = []): HttpResponse 77 | { 78 | return $this->request('POST', $uri, $options); 79 | } 80 | 81 | /** 82 | * Create and send an HTTP PATCH request. 83 | * 84 | * @param string $uri URI object or string. 85 | * @param HttpOptions|array $options Request options to apply. 86 | * 87 | * @return HttpResponse 88 | */ 89 | public function patch(string $uri, HttpOptions|array $options = []): HttpResponse 90 | { 91 | return $this->request('PATCH', $uri, $options); 92 | } 93 | 94 | /** 95 | * Create and send an HTTP DELETE request. 96 | * 97 | * @param string $uri URI object or string. 98 | * @param HttpOptions|array $options Request options to apply. 99 | * 100 | * @return HttpResponse 101 | */ 102 | public function delete(string $uri, HttpOptions|array $options = []): HttpResponse 103 | { 104 | return $this->request('DELETE', $uri, $options); 105 | } 106 | 107 | } -------------------------------------------------------------------------------- /src/Chunk.php: -------------------------------------------------------------------------------- 1 | $id, 61 | 'startByte' => -1, 62 | 'endByte' => -1, 63 | 'length' => -1, 64 | 'elapsedTime' => -1, 65 | 'localPath' => $local_path, 66 | 'body' => null, 67 | 'status' => 'init', 68 | 'errorCode' => null, 69 | 'errorMessage' => null, 70 | ], $data)); 71 | } 72 | 73 | /** 74 | * Save the chunk to the local path 75 | * 76 | * @param string|null $localPath [optional] The local path to save the chunk 77 | * @return bool 78 | */ 79 | public function save(string $localPath = null): bool 80 | { 81 | $localPath = $localPath ?? $this->getLocalPath(); 82 | $body = $this->getBody(); 83 | if (!$body) { 84 | return false; 85 | } 86 | return file_put_contents($localPath, $body) !== false; 87 | } 88 | 89 | /** 90 | * Delete the chunk from the local path 91 | * 92 | * @param string|null $localPath [optional] The local path to delete the chunk 93 | * @return bool 94 | */ 95 | public function delete(string $localPath = null): bool 96 | { 97 | $localPath = $localPath ?? $this->getLocalPath(); 98 | return unlink($localPath); 99 | } 100 | 101 | } -------------------------------------------------------------------------------- /src/Enums/ErrorCode.php: -------------------------------------------------------------------------------- 1 | 'CURLE_UNSUPPORTED_PROTOCOL', 17 | 2 => 'CURLE_FAILED_INIT', 18 | 3 => 'CURLE_URL_MALFORMAT', 19 | 4 => 'CURLE_URL_MALFORMAT_USER', 20 | 5 => 'CURLE_COULDNT_RESOLVE_PROXY', 21 | 6 => 'CURLE_COULDNT_RESOLVE_HOST', 22 | 7 => 'CURLE_COULDNT_CONNECT', 23 | 8 => 'CURLE_FTP_WEIRD_SERVER_REPLY', 24 | 9 => 'CURLE_REMOTE_ACCESS_DENIED', 25 | 11 => 'CURLE_FTP_WEIRD_PASS_REPLY', 26 | 13 => 'CURLE_FTP_WEIRD_PASV_REPLY', 27 | 14 => 'CURLE_FTP_WEIRD_227_FORMAT', 28 | 15 => 'CURLE_FTP_CANT_GET_HOST', 29 | 17 => 'CURLE_FTP_COULDNT_SET_TYPE', 30 | 18 => 'CURLE_PARTIAL_FILE', 31 | 19 => 'CURLE_FTP_COULDNT_RETR_FILE', 32 | 21 => 'CURLE_QUOTE_ERROR', 33 | 22 => 'CURLE_HTTP_RETURNED_ERROR', 34 | 23 => 'CURLE_WRITE_ERROR', 35 | 25 => 'CURLE_UPLOAD_FAILED', 36 | 26 => 'CURLE_READ_ERROR', 37 | 27 => 'CURLE_OUT_OF_MEMORY', 38 | 28 => 'CURLE_OPERATION_TIMEDOUT', 39 | 30 => 'CURLE_FTP_PORT_FAILED', 40 | 31 => 'CURLE_FTP_COULDNT_USE_REST', 41 | 33 => 'CURLE_RANGE_ERROR', 42 | 34 => 'CURLE_HTTP_POST_ERROR', 43 | 35 => 'CURLE_SSL_CONNECT_ERROR', 44 | 36 => 'CURLE_BAD_DOWNLOAD_RESUME', 45 | 37 => 'CURLE_FILE_COULDNT_READ_FILE', 46 | 38 => 'CURLE_LDAP_CANNOT_BIND', 47 | 39 => 'CURLE_LDAP_SEARCH_FAILED', 48 | 41 => 'CURLE_FUNCTION_NOT_FOUND', 49 | 42 => 'CURLE_ABORTED_BY_CALLBACK', 50 | 43 => 'CURLE_BAD_FUNCTION_ARGUMENT', 51 | 45 => 'CURLE_INTERFACE_FAILED', 52 | 47 => 'CURLE_TOO_MANY_REDIRECTS', 53 | 48 => 'CURLE_UNKNOWN_TELNET_OPTION', 54 | 49 => 'CURLE_TELNET_OPTION_SYNTAX', 55 | 51 => 'CURLE_PEER_FAILED_VERIFICATION', 56 | 52 => 'CURLE_GOT_NOTHING', 57 | 53 => 'CURLE_SSL_ENGINE_NOTFOUND', 58 | 54 => 'CURLE_SSL_ENGINE_SETFAILED', 59 | 55 => 'CURLE_SEND_ERROR', 60 | 56 => 'CURLE_RECV_ERROR', 61 | 58 => 'CURLE_SSL_CERTPROBLEM', 62 | 59 => 'CURLE_SSL_CIPHER', 63 | 60 => 'CURLE_SSL_CACERT', 64 | 61 => 'CURLE_BAD_CONTENT_ENCODING', 65 | 62 => 'CURLE_LDAP_INVALID_URL', 66 | 63 => 'CURLE_FILESIZE_EXCEEDED', 67 | 64 => 'CURLE_USE_SSL_FAILED', 68 | 65 => 'CURLE_SEND_FAIL_REWIND', 69 | 66 => 'CURLE_SSL_ENGINE_INITFAILED', 70 | 67 => 'CURLE_LOGIN_DENIED', 71 | 68 => 'CURLE_TFTP_NOTFOUND', 72 | 69 => 'CURLE_TFTP_PERM', 73 | 70 => 'CURLE_REMOTE_DISK_FULL', 74 | 71 => 'CURLE_TFTP_ILLEGAL', 75 | 72 => 'CURLE_TFTP_UNKNOWNID', 76 | 73 => 'CURLE_REMOTE_FILE_EXISTS', 77 | 74 => 'CURLE_TFTP_NOSUCHUSER', 78 | 75 => 'CURLE_CONV_FAILED', 79 | 76 => 'CURLE_CONV_REQD', 80 | 77 => 'CURLE_SSL_CACERT_BADFILE', 81 | 78 => 'CURLE_REMOTE_FILE_NOT_FOUND', 82 | 79 => 'CURLE_SSH', 83 | 80 => 'CURLE_SSL_SHUTDOWN_FAILED', 84 | 81 => 'CURLE_AGAIN', 85 | 82 => 'CURLE_SSL_CRL_BADFILE', 86 | 83 => 'CURLE_SSL_ISSUER_ERROR', 87 | 84 => 'CURLE_FTP_PRET_FAILED', 88 | 85 => 'CURLE_RTSP_CSEQ_ERROR', 89 | 86 => 'CURLE_RTSP_SESSION_ERROR', 90 | 87 => 'CURLE_FTP_BAD_FILE_LIST', 91 | 88 => 'CURLE_CHUNK_FAILED' 92 | ]; 93 | 94 | /** 95 | * Get the error message for the given error code. 96 | * 97 | * @param int $code 98 | * @return string 99 | */ 100 | public static function getMessage(int $code): string 101 | { 102 | return self::$messages[$code] ?? 'UNKNOWN_ERROR'; 103 | } 104 | 105 | } -------------------------------------------------------------------------------- /src/Model/HttpResponse.php: -------------------------------------------------------------------------------- 1 | curlHandle = $curlHandle; 43 | return $this; 44 | } 45 | 46 | /** 47 | * Get info from the curl handle 48 | * 49 | * @return CurlInfo|false 50 | */ 51 | public function getInfo(): CurlInfo|false 52 | { 53 | if (empty($this->getCurlHandle())) { 54 | return false; 55 | } 56 | 57 | return new CurlInfo(curl_getinfo($this->curlHandle)); 58 | } 59 | 60 | /** 61 | * Set the headers of the response 62 | * 63 | * @param string $headers 64 | * @return HttpResponse 65 | */ 66 | public function setHeaders(string $headers): HttpResponse 67 | { 68 | $result = []; 69 | $lines = explode("\r\n", $headers); 70 | foreach ($lines as $line) { 71 | if (str_contains($line, ':')) { 72 | $parts = explode(':', $line); 73 | $result[trim($parts[0])] = trim($parts[1]); 74 | } 75 | } 76 | $this->headers = $result; 77 | return $this; 78 | } 79 | 80 | /** 81 | * Get a key from the response headers 82 | * 83 | * @param string $key 84 | * @return mixed 85 | */ 86 | public function getHeaderLine(string $key): mixed 87 | { 88 | return array_change_key_case($this->headers, CASE_LOWER)[strtolower($key)] ?? null; 89 | } 90 | 91 | /** 92 | * @param string $name 93 | * @param array $arguments 94 | * @return mixed 95 | */ 96 | public function __call(string $name, array $arguments): mixed 97 | { 98 | if (property_exists($this, $name)) { 99 | return $this->{$name}; 100 | } 101 | 102 | if (method_exists($this, $name)) { 103 | return $this->{$name}(); 104 | } 105 | 106 | if (str_starts_with($name, 'get')) { 107 | $property = lcfirst(substr($name, 3)); 108 | return $this->{$property} ?? null; 109 | } 110 | 111 | if (str_starts_with($name, 'set')) { 112 | $property = lcfirst(substr($name, 3)); 113 | $this->{$property} = $arguments[0] ?? null; 114 | return $this; 115 | } 116 | 117 | throw new \BadMethodCallException("Method $name does not exist"); 118 | } 119 | 120 | } -------------------------------------------------------------------------------- /src/Utils/Toolkit.php: -------------------------------------------------------------------------------- 1 | 0) { 28 | $url .= '?' . http_build_query($params); 29 | } 30 | return $url; 31 | } 32 | 33 | /** 34 | * Generates a random string 35 | * 36 | * @param int $length The length of the string 37 | * @return string 38 | */ 39 | public static function randomString(int $length = 10): string 40 | { 41 | $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; 42 | $charactersLength = strlen($characters); 43 | $randomString = ''; 44 | for ($i = 0; $i < $length; $i++) { 45 | $randomString .= $characters[rand(0, $charactersLength - 1)]; 46 | } 47 | return $randomString; 48 | } 49 | 50 | /** 51 | * Make Json string pretty 52 | * 53 | * @param string $json The json string 54 | * @return string 55 | */ 56 | public static function prettyJson(string $json): string 57 | { 58 | return json_encode(json_decode($json), JSON_PRETTY_PRINT); 59 | } 60 | 61 | /** 62 | * Convert bytes to human-readable format 63 | * 64 | * @param int $bytes The bytes 65 | * @param bool $binaryPrefix Whether to use binary prefixes 66 | * @return string 67 | */ 68 | public static function bytesToHuman(int $bytes, bool $binaryPrefix = true): string 69 | { 70 | if ($binaryPrefix) { 71 | $unit = array('B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'); 72 | if ($bytes == 0) { 73 | return '0 ' . $unit[0]; 74 | } 75 | 76 | return @round($bytes / pow(1024, ($i = floor(log($bytes, 1024)))), 2) . ' ' . ($unit[$i] ?? 'B'); 77 | } else { 78 | $unit = array('B', 'KB', 'MB', 'GB', 'TB', 'PB'); 79 | if ($bytes == 0) { 80 | return '0 ' . $unit[0]; 81 | } 82 | 83 | return @round($bytes / pow(1000, ($i = floor(log($bytes, 1000)))), 2) . ' ' . ($unit[$i] ?? 'B'); 84 | } 85 | } 86 | 87 | /** 88 | * Validate insensitive case string 89 | * 90 | * @param string $string The string 91 | * @param string $value The second value to compare with 92 | * @return bool 93 | */ 94 | public static function insensitiveString(string $string, string $value): bool 95 | { 96 | return preg_match_all('/' . $value . '/i', $string) !== 0; 97 | } 98 | 99 | /** 100 | * Millisecond sleep 101 | * 102 | * @param int $milliseconds The milliseconds 103 | * @return void 104 | */ 105 | public static function sleep(int $milliseconds): void 106 | { 107 | usleep($milliseconds * 1000); 108 | } 109 | 110 | /** 111 | * Get current time in milliseconds 112 | * 113 | * @return int 114 | */ 115 | public static function time(): int 116 | { 117 | return (int)(microtime(true) * 1000); 118 | } 119 | 120 | /** 121 | * Helper to convert a binary to a string of '0' and '1'. 122 | * 123 | * @param string $string 124 | * @return string 125 | */ 126 | public static function sprintB(string $string): string 127 | { 128 | $return = ''; 129 | $strLen = strlen($string); 130 | for ($i = 0; $i < $strLen; $i++) { 131 | $return .= sprintf('%08b', ord($string[$i])); 132 | } 133 | 134 | return $return; 135 | } 136 | 137 | } -------------------------------------------------------------------------------- /src/Model/DownloadResult.php: -------------------------------------------------------------------------------- 1 | 70 | */ 71 | public array $chunks; 72 | 73 | /** 74 | * Add a chunk to the download result 75 | * 76 | * @param string $id The unique identifier of the chunk 77 | * @param ?string $body The chunk body 78 | * @param float $elapsedTime in microseconds 79 | * @return void 80 | */ 81 | public function addChunk(string $id, ?string $body, float $elapsedTime): void 82 | { 83 | $chunk = new Chunk([ 84 | 'identifier' => $id, 85 | 'body' => $body, 86 | 'elapsedTime' => $elapsedTime, 87 | 'length' => 0, 88 | ]); 89 | $save = $this->saveChunk($id, $body); 90 | 91 | $chunk->setStatus($save ? 'downloaded' : 'failed'); 92 | $chunk->setLength(mb_strlen($body)); 93 | 94 | $this->chunks[] = $chunk; 95 | } 96 | 97 | /** 98 | * Save the chunks to the temp directory 99 | * 100 | * @param string $id The unique identifier of the chunk 101 | * @param string $body The body of the chunk 102 | * @return string|bool 103 | */ 104 | private function saveChunk(string $id, string $body): string|bool 105 | { 106 | $path = $this->chunksPath . DIRECTORY_SEPARATOR . $id; 107 | return file_put_contents($path, $body) ? $path : false; 108 | } 109 | 110 | /** 111 | * Save the merged chunks to a file 112 | * 113 | * @param string $filePath The path/to/file.ext 114 | * @return bool 115 | */ 116 | public function save(string $filePath): bool 117 | { 118 | $pathInfo = pathinfo($filePath, PATHINFO_DIRNAME); 119 | if (gettype($pathInfo) != "string") $pathInfo = $pathInfo['dirname']; 120 | if (!file_exists($pathInfo)) { 121 | throw new \InvalidArgumentException('The directory does not exist'); 122 | } 123 | $result = $this->mergeChunks(); 124 | $this->cleanChunks(); 125 | return file_put_contents($filePath, $result) !== false; 126 | } 127 | 128 | /** 129 | * Merge the chunks into a single string 130 | * 131 | * @return string 132 | */ 133 | public function mergeChunks(): string 134 | { 135 | $result = ''; 136 | foreach ($this->chunks as $chunk) { 137 | $result .= file_get_contents($chunk->getLocalPath()); 138 | } 139 | return $result; 140 | } 141 | 142 | /** 143 | * Clean the directory of the chunks 144 | * 145 | * @return void 146 | */ 147 | public function cleanChunks(): void 148 | { 149 | foreach (glob($this->chunksPath . DIRECTORY_SEPARATOR . '*') as $file) { 150 | unlink($file); 151 | } 152 | rmdir($this->chunksPath); 153 | } 154 | 155 | } -------------------------------------------------------------------------------- /docs/breakdown-large-request.md: -------------------------------------------------------------------------------- 1 | # Breakdown of Large Request 2 | 3 | On this page, we will show you how to break down a large request into smaller requests and make the process of sending 4 | the requests easier and faster. 5 | 6 |
7 | 8 | #### Table of Contents 9 | 10 | - [Initialize Environments](#initialize-environments) 11 | - [Normal CURL](#normal-curl) 12 | - [Send the request](#normal-curl-send-the-request) 13 | - [Bulk Request](#bulk-request) 14 | - [Creating the Requests](#bulk-request-create-requests) 15 | - [Send Requests](#bulk-request-send-requests) 16 | - [Result](#result) 17 | 18 |
19 | 20 | ### Getting Started 21 | 22 | The following is a breakdown of the request we will get data from `API` of `TradingView` and the source code of this 23 | example is available at [here](../examples/bulk-request/breakdown-large-request.php). 24 | 25 |
26 | 27 | #### Initialize Environments 28 | 29 | The `$postData` variable is the data that we will send to the API and you can customize it to your needs. 30 | 31 | ```php 32 | require_once $_SERVER['DOCUMENT_ROOT'] . '/vendor/autoload.php'; 33 | $client = new \EasyHttp\HttpClient(); 34 | $endPoint = 'https://scanner.tradingview.com/america/scan'; 35 | $postData = [ 36 | 'filter' => [ 37 | [ 38 | 'left' => "exchange", 39 | 'operation' => "in_range", 40 | 'right' => [ 41 | 'AMEX', 42 | 'NASDAQ', 43 | 'NYSE' 44 | ], 45 | ], 46 | [ 47 | 'left' => "is_primary", 48 | 'operation' => "equal", 49 | 'right' => true, 50 | ], 51 | [ 52 | 'left' => "change", 53 | 'operation' => "nempty", 54 | ] 55 | ], 56 | 'options' => [ 57 | 'lang' => "en", 58 | 'active_symbols_only' => true, 59 | ], 60 | 'markets' => [ 61 | "america" 62 | ], 63 | 'columns' => [ 64 | "logoid", 65 | "name", 66 | "close", 67 | // And so on... 68 | ], 69 | 'sort' => [ 70 | 'sortBy' => "change", 71 | 'sortOrder' => "desc" 72 | ] 73 | ]; 74 | ``` 75 | 76 |
77 | 78 | ### Normal CURL 79 | 80 | On this part we will show you how to send the request using the normal CURL. 81 | 82 | #### Normal CURL: Send the request 83 | 84 | ```php 85 | $startTime = microtime(true); 86 | $response = $client->post($endPoint, [ 87 | 'headers' => [ 88 | 'Content-Type' => 'application/json', 89 | 'Accept' => 'application/json', 90 | ], 91 | 'body' => array_merge($postData, [ 92 | 'range' => [ 93 | 0, 94 | 2000 95 | ] 96 | ]), 97 | ]); 98 | echo '
' . "Normal CURL - Total time: " . (microtime(true) - $startTime) . " - Memory: $Memory" . '
'; 99 | ``` 100 | 101 |
102 | 103 | ### Bulk Request 104 | 105 | On this part we will show you how to send the request using the bulk request. 106 | 107 | #### Bulk Request: Create Requests 108 | 109 | ```php 110 | $requests = []; 111 | for ($i = 0; $i < 2000; $i += 500) { 112 | $requests[] = [ 113 | 'uri' => $endPoint, 114 | 'options' => [ 115 | 'headers' => [ 116 | 'Content-Type' => 'application/json', 117 | 'Accept' => 'application/json', 118 | ], 119 | 'body' => array_merge($postData, [ 120 | 'range' => [ 121 | $i, 122 | $i + 500 123 | ] 124 | ]), 125 | ] 126 | ]; 127 | } 128 | ``` 129 | 130 | #### Bulk Request: Send Requests 131 | 132 | ```php 133 | $start = microtime(true); 134 | $responses = $client->bulk($requests); 135 | 136 | foreach ($responses as $response) { 137 | $Data = json_decode($response->getBody(), true); 138 | } 139 | 140 | $Memory = \EasyHttp\Utils\Toolkit::bytesToHuman(memory_get_usage()); 141 | echo '
' . "Bulk Request - Total time: " . (microtime(true) - $start) . " - Memory: $Memory" . '
'; 142 | ``` 143 | 144 |
145 | 146 | #### Result 147 | 148 | The result of this example is showing us that the bulk request is much faster than the normal request. 149 | 150 | ```txt 151 | Normal CURL - Total time: 6.0051791667938 - Memory: 10.21 MiB 152 | Bulk Request - Total time: 2.0174889564514 - Memory: 7.91 MiB 153 | ``` -------------------------------------------------------------------------------- /tests/WebSocketClientTest.php: -------------------------------------------------------------------------------- 1 | on('open', function (WebSocket $socket) { 21 | $this->assertTrue($socket->isConnected()); 22 | echo "Connected to" . PHP_EOL; 23 | $socket->send('Hello World'); 24 | }); 25 | 26 | $ws->on('message', function (WebSocket $socket, $message) { 27 | $this->assertEquals('Hello World', $message); 28 | $socket->close(); 29 | echo $message; 30 | }); 31 | 32 | $ws->connect($this->url, (new WebSocketConfig())->setHeaders([ 33 | 'ssl' => [ 34 | 'verify_peer' => false, 35 | 'verify_peer_name' => false, 36 | ] 37 | ])); 38 | } 39 | 40 | public function testHeadersAreWorking(): void 41 | { 42 | $randomString = Toolkit::randomString(32); 43 | 44 | $ws = new WebSocket(); 45 | 46 | $ws->on('open', function ($socket) { 47 | $socket->send('Headers'); 48 | }); 49 | 50 | $ws->on('message', function ($socket, $message) use ($randomString) { 51 | $headers = json_decode($message, true); 52 | $this->assertEquals($randomString, $headers['x-subscribe-with']); 53 | $socket->close(); 54 | }); 55 | 56 | $ws->connect($this->url, (new WebSocketConfig())->setHeaders([ 57 | 'X-Subscribe-With' => $randomString, 58 | 'ssl' => [ 59 | 'verify_peer' => false, 60 | 'verify_peer_name' => false, 61 | ] 62 | ])); 63 | } 64 | 65 | /** 66 | * @return void 67 | * @throws \Exception 68 | */ 69 | public function testCanSendLargePayload(): void 70 | { 71 | $sendMe = []; 72 | foreach (range(0, 100) as $i) { 73 | $sendMe[] = [ 74 | 'id' => $i, 75 | 'name' => "Madame Uppercut", 76 | 'age' => rand(1, 100), 77 | 'secretIdentity' => "Jane Wilson", 78 | 'powers' => [ 79 | "million tonne punch", 80 | "damage resistance", 81 | "superhuman reflexes" 82 | ] 83 | ]; 84 | } 85 | 86 | $ws = new WebSocket(); 87 | 88 | $ws->on('open', function (WebSocket $socket) use ($sendMe) { 89 | $socket->send(json_encode($sendMe)); 90 | }); 91 | 92 | $ws->on('message', function (WebSocket $socket, $message) use ($sendMe) { 93 | $this->assertEquals('Message: ' . json_encode($sendMe), $message); 94 | $socket->close(); 95 | }); 96 | 97 | $ws->connect($this->url, (new WebSocketConfig())->setHeaders([ 98 | 'ssl' => [ 99 | 'verify_peer' => false, 100 | 'verify_peer_name' => false, 101 | ] 102 | ])); 103 | } 104 | 105 | /** 106 | * @return void 107 | * @throws \Exception 108 | */ 109 | public function testWithClient(): void 110 | { 111 | $ws = new WebSocket(new class extends SocketClient { 112 | 113 | public function onOpen(WebSocket $socket): void 114 | { 115 | $socket->send('Hello World'); 116 | } 117 | 118 | public function onClose(WebSocket $socket, int $closeStatus): void 119 | { 120 | echo "Closed with status: $closeStatus"; 121 | } 122 | 123 | public function onError(WebSocket $socket, WebSocketException $exception): void 124 | { 125 | echo sprintf( 126 | "Error: %s\nFile: %s:%s\n", 127 | $exception->getMessage(), 128 | $exception->getFile(), 129 | $exception->getLine() 130 | ); 131 | } 132 | 133 | public function onMessage(WebSocket $socket, string $message): void 134 | { 135 | $socket->close(); 136 | } 137 | 138 | }); 139 | 140 | $ws->connect($this->url, (new WebSocketConfig())->setHeaders([ 141 | 'ssl' => [ 142 | 'verify_peer' => false, 143 | 'verify_peer_name' => false, 144 | ] 145 | ])); 146 | 147 | $this->assertFalse($ws->isConnected()); 148 | } 149 | 150 | } -------------------------------------------------------------------------------- /examples/bulk-request/breakdown-large-request.php: -------------------------------------------------------------------------------- 1 | 8 |

Breakdown Large Request

9 | On this example we will require 2,000 stocks from the TradingView API, and then we will display the time and memory usage of the script.
10 |
Note: If you saw the "total time" does not make sense, it is a fault of the server or your network.
11 | Disclaimer: This is just a code for purpose of education. It's illegal and should not be used in production.

12 |

13 | HTML; 14 | 15 | // --------------------- ====== --------------------- // 16 | 17 | $client = new \EasyHttp\HttpClient(); 18 | $endpoint = 'https://scanner.tradingview.com/america/scan'; 19 | $post = [ 20 | 'filter' => [ 21 | [ 22 | 'left' => "exchange", 23 | 'operation' => "in_range", 24 | 'right' => [ 25 | 'AMEX', 26 | 'NASDAQ', 27 | 'NYSE' 28 | ], 29 | ], 30 | [ 31 | 'left' => "is_primary", 32 | 'operation' => "equal", 33 | 'right' => true, 34 | ], 35 | [ 36 | 'left' => "change", 37 | 'operation' => "nempty", 38 | ] 39 | ], 40 | 'options' => [ 41 | 'lang' => "en", 42 | 'active_symbols_only' => true, 43 | ], 44 | 'markets' => [ 45 | "america" 46 | ], 47 | 'columns' => [ 48 | "logoid", 49 | "name", 50 | "change|1", 51 | "change|5", 52 | "change|15", 53 | "change|60", 54 | "change|240", 55 | "change", 56 | "change|1W", 57 | "change|1M", 58 | "Perf.3M", 59 | "Perf.6M", 60 | "Perf.YTD", 61 | "Perf.Y", 62 | "Volatility.D", 63 | "description", 64 | "type", 65 | "subtype", 66 | "update_mode", 67 | "currency", 68 | "fundamental_currency_code", 69 | "Recommend.All", 70 | "RSI", 71 | "RSI[1]", 72 | "Stoch.K", 73 | "Stoch.D", 74 | "Stoch.K[1]", 75 | "Stoch.D[1]", 76 | "CCI20", 77 | "CCI20[1]", 78 | "AO", 79 | "AO[1]", 80 | "AO[2]", 81 | "Mom", 82 | "Mom[1]", 83 | "MACD.macd", 84 | "MACD.signal", 85 | "Rec.Stoch.RSI", 86 | "Stoch.RSI.K", 87 | "Rec.WR", 88 | "W.R", 89 | "Rec.BBPower", 90 | "BBPower", 91 | "Rec.UO", 92 | "UO", 93 | "EMA10", 94 | "close", 95 | "SMA10", 96 | "EMA20", 97 | "SMA20", 98 | "EMA30", 99 | "SMA30", 100 | "EMA50", 101 | "SMA50", 102 | "EMA100", 103 | "SMA100", 104 | "EMA200", 105 | "SMA200", 106 | "Rec.Ichimoku", 107 | "Ichimoku.BLine", 108 | "Rec.VWMA", 109 | "VWMA", 110 | "Rec.HullMA9", 111 | "HullMA9" 112 | ], 113 | 'sort' => [ 114 | 'sortBy' => "change", 115 | 'sortOrder' => "desc" 116 | ] 117 | ]; 118 | 119 | $start = microtime(true); 120 | $Response = $client->post($endpoint, [ 121 | 'headers' => [ 122 | 'Content-Type' => 'application/json', 123 | 'Accept' => 'application/json', 124 | ], 125 | 'body' => array_merge($post, [ 126 | 'range' => [ 127 | 0, 128 | 2000 129 | ] 130 | ]), 131 | ]); 132 | 133 | $Data = json_decode($Response->getBody(), true); 134 | $Memory = \EasyHttp\Utils\Toolkit::bytesToHuman(memory_get_usage()); 135 | 136 | echo '
' . "Normal CURL - Total time: " . (microtime(true) - $start) . " - Memory: $Memory" . '
'; 137 | echo '
'; 138 | 139 | // --------------------- ====== --------------------- // 140 | 141 | // Initializes the request 142 | $requests = []; 143 | for ($i = 0; $i < 2000; $i += 500) { 144 | $requests[] = [ 145 | 'method' => 'POST', 146 | 'uri' => $endpoint, 147 | 'options' => [ 148 | 'headers' => [ 149 | 'Content-Type' => 'application/json', 150 | 'Accept' => 'application/json', 151 | ], 152 | 'body' => array_merge($post, [ 153 | 'range' => [ 154 | $i, 155 | $i + 500 156 | ] 157 | ]), 158 | ] 159 | ]; 160 | } 161 | 162 | // --------------------- ====== --------------------- // 163 | 164 | $start = microtime(true); 165 | $responses = $client->bulk($requests); 166 | 167 | foreach ($responses as $response) { 168 | $Data = json_decode($response->getBody(), true); 169 | } 170 | 171 | $Memory = \EasyHttp\Utils\Toolkit::bytesToHuman(memory_get_usage()); 172 | echo '
' . "Bulk Request - Total time: " . (microtime(true) - $start) . " - Memory: $Memory" . '
'; -------------------------------------------------------------------------------- /src/HttpClient.php: -------------------------------------------------------------------------------- 1 | tempDir = $_SERVER['TEMP'] ?? null; 28 | $this->setHasSelfSignedCertificate(true); 29 | } 30 | 31 | /** 32 | * Set has self-signed certificate 33 | * 34 | * This is used to set the curl option CURLOPT_SSL_VERIFYPEER 35 | * and CURLOPT_SSL_VERIFYHOST to false. This is useful when you are 36 | * in local environment, or you have self-signed certificate. 37 | * 38 | * @param bool $has 39 | * 40 | * @return void 41 | */ 42 | public function setHasSelfSignedCertificate(bool $has): void 43 | { 44 | putenv('HAS_SELF_SIGNED_CERT=' . ($has ? 'true' : 'false')); 45 | } 46 | 47 | /** 48 | * This method is used to send a http request to a given url. 49 | * 50 | * @param string $method 51 | * @param string $uri 52 | * @param array|HttpOptions $options 53 | * 54 | * @return HttpResponse 55 | */ 56 | public function request(string $method, string $uri, array|HttpOptions $options = []): HttpResponse 57 | { 58 | $CurlHandle = Middleware::create_curl_handler($method, $uri, $options); 59 | if (!$CurlHandle) { 60 | throw new RuntimeException('An error occurred while creating the curl handler'); 61 | } 62 | 63 | $result = new HttpResponse(); 64 | $result->setCurlHandle($CurlHandle); 65 | 66 | $response = curl_exec($CurlHandle); 67 | if (curl_errno($CurlHandle) || !$response) { 68 | throw new RuntimeException( 69 | sprintf('An error occurred while sending the request: %s', curl_error($CurlHandle)), 70 | curl_errno($CurlHandle) 71 | ); 72 | } 73 | 74 | $result->setStatusCode(curl_getinfo($CurlHandle, CURLINFO_HTTP_CODE)); 75 | $result->setHeaderSize(curl_getinfo($CurlHandle, CURLINFO_HEADER_SIZE)); 76 | $result->setHeaders(substr((string)$response, 0, $result->getHeaderSize())); 77 | $result->setBody(substr((string)$response, $result->getHeaderSize())); 78 | 79 | curl_close($CurlHandle); 80 | 81 | return $result; 82 | } 83 | 84 | /** 85 | * Send multiple requests to a given url. 86 | * 87 | * @param array $requests [{method, uri, options}, ...] 88 | * 89 | * @return array 90 | */ 91 | public function bulk(array $requests): array 92 | { 93 | $result = []; 94 | $handlers = []; 95 | $multi_handler = curl_multi_init(); 96 | foreach ($requests as $request) { 97 | 98 | $CurlHandle = Middleware::create_curl_handler( 99 | $request['method'] ?? null, 100 | $request['uri'], 101 | $request['options'] ?? [] 102 | ); 103 | if (!$CurlHandle) { 104 | throw new RuntimeException( 105 | 'An error occurred while creating the curl handler' 106 | ); 107 | } 108 | $handlers[] = $CurlHandle; 109 | curl_multi_add_handle($multi_handler, $CurlHandle); 110 | 111 | } 112 | 113 | $active = -1; 114 | do { 115 | $mrc = curl_multi_exec($multi_handler, $active); 116 | } while ($mrc == CURLM_CALL_MULTI_PERFORM); 117 | 118 | while ($active && $mrc == CURLM_OK) { 119 | if (curl_multi_select($multi_handler) != -1) { 120 | do { 121 | $mrc = curl_multi_exec($multi_handler, $active); 122 | } while ($mrc == CURLM_CALL_MULTI_PERFORM); 123 | } 124 | } 125 | 126 | foreach ($handlers as $handler) { 127 | curl_multi_remove_handle($multi_handler, $handler); 128 | } 129 | curl_multi_close($multi_handler); 130 | 131 | foreach ($handlers as $handler) { 132 | $content = curl_multi_getcontent($handler); 133 | $response = new HttpResponse(); 134 | 135 | if (curl_errno($handler)) { 136 | throw new RuntimeException( 137 | sprintf('An error occurred while sending the request: %s', curl_error($handler)), 138 | curl_errno($handler) 139 | ); 140 | } 141 | 142 | $response->setCurlHandle($handler); 143 | $response->setStatusCode(curl_getinfo($handler, CURLINFO_HTTP_CODE)); 144 | $response->setHeaderSize(curl_getinfo($handler, CURLINFO_HEADER_SIZE)); 145 | $response->setHeaders(substr($content, 0, $response->getHeaderSize())); 146 | $response->setBody(substr($content, $response->getHeaderSize())); 147 | 148 | $result[] = $response; 149 | } 150 | 151 | return $result; 152 | } 153 | 154 | } -------------------------------------------------------------------------------- /src/Enums/CurlInfo.php: -------------------------------------------------------------------------------- 1 | $value) { 59 | $data[$key] = $this->formatKey($key); 60 | } 61 | parent::__construct($data); 62 | } 63 | 64 | /** 65 | * Key formatter 66 | * 67 | * @param string $key 68 | * @return string 69 | */ 70 | protected function formatKey(string $key): string 71 | { 72 | // Converts from UPPER_CASE to snake_case 73 | return mb_strtolower(preg_replace('/(?Content-type: text/plain', 'Content-length: 100') 32 | * 33 | * @var array 34 | */ 35 | private array $headers = []; 36 | 37 | /** 38 | * An array of cookies to set, in the format array('name' => 'value', 'name2' => 'value2') 39 | * 40 | * @var array 41 | */ 42 | private array $cookie = []; 43 | 44 | /** 45 | * An array of query data (e.g., array('id' => '123', 'name' => 'John')) for use in the query string part of the URI (e.g., http://example.com/index.php?id=123&name=John) 46 | * 47 | * @var array 48 | */ 49 | private array $query = []; 50 | 51 | /** 52 | * The body of the HTTP request 53 | * 54 | * @var ?string 55 | */ 56 | private ?string $body = null; 57 | 58 | /** 59 | * The maximum number of seconds to allow cURL functions to execute 60 | * 61 | * @var int 62 | */ 63 | private int $timeout = 30; 64 | 65 | /** 66 | * An array of multipart data (e.g., array('name', 'contents', 'size')), for use in the multipart/form-data part of the request body 67 | * 68 | * @var array 69 | */ 70 | private array $multipart = []; 71 | 72 | /** 73 | * An array of cURL options 74 | * 75 | * @var array 76 | */ 77 | private array $curlOptions = []; 78 | 79 | /** 80 | * The proxy server to use 81 | * 82 | * @var ?ProxyServer 83 | */ 84 | private ?ProxyServer $proxy = null; 85 | 86 | /** 87 | * Http Options constructor. 88 | * 89 | * @param array $options 90 | */ 91 | public function __construct(array $options = []) 92 | { 93 | $this->setOptions($options); 94 | } 95 | 96 | /** 97 | * Set Options 98 | * 99 | * @param array $options 100 | * @return void 101 | */ 102 | public function setOptions(array $options): void 103 | { 104 | foreach ($options as $key => $value) { 105 | if (method_exists($this, 'set' . ucfirst($key))) { 106 | if ($value !== null) { 107 | $this->{'set' . ucfirst($key)}($value); 108 | } 109 | } else { 110 | if (property_exists($this, $key)) { 111 | $this->{$key} = $value; 112 | } else { 113 | throw new \InvalidArgumentException("Invalid option: $key"); 114 | } 115 | } 116 | } 117 | } 118 | 119 | /** 120 | * Returns the class as an array 121 | * 122 | * @return array 123 | */ 124 | public function toArray(): array 125 | { 126 | $result = []; 127 | foreach (get_object_vars($this) as $key => $value) { 128 | $result[$key] = $value; 129 | } 130 | return $result; 131 | } 132 | 133 | /** 134 | * Set Body of Http request 135 | * 136 | * @param ?string|array $body The body of the request - On array it will be converted to json 137 | * @return void 138 | */ 139 | public function setBody(string|array|null $body): void 140 | { 141 | if (is_array($body)) { 142 | $this->body = json_encode($body); 143 | $this->headers['Content-Type'] = 'application/json'; 144 | } else { 145 | $this->body = $body; 146 | } 147 | } 148 | 149 | /** 150 | * Set proxy server 151 | * 152 | * @param array $proxy ["host", "port", "user", "pass"] 153 | * @return void 154 | */ 155 | public function setProxy(array $proxy): void 156 | { 157 | $this->proxy = (new ProxyServer())->setProxy($proxy); 158 | } 159 | 160 | /** 161 | * Generate URL-encoded query string. 162 | * 163 | * @return string 164 | */ 165 | public function getQueryString(): string 166 | { 167 | return http_build_query($this->query); 168 | } 169 | 170 | /** 171 | * Set Curl Options 172 | * 173 | * @param array $options [{"CURLOPT_*": "value"}, ...] 174 | * @return void 175 | */ 176 | public function setCurlOptions(array $options): void 177 | { 178 | if (count($options) > 0) { 179 | foreach ($options as $option => $value) { 180 | $this->curlOptions[$option] = $value; 181 | } 182 | } 183 | } 184 | 185 | /** 186 | * Add Multipart Data 187 | * 188 | * @param array $multipart [{"name", "path"}, ...] 189 | * @return void 190 | */ 191 | public function addMultiPart(array $multipart): void 192 | { 193 | $this->multipart[] = $multipart; 194 | } 195 | 196 | /** 197 | * @param string $name 198 | * @param array $arguments 199 | * @return mixed 200 | */ 201 | public function __call(string $name, array $arguments): mixed 202 | { 203 | if (method_exists($this, $name)) { 204 | return $this->{$name}(...$arguments); 205 | } 206 | 207 | if (property_exists($this, $name)) { 208 | return $this->{$name}; 209 | } 210 | 211 | if (str_starts_with($name, 'set')) { 212 | $property = lcfirst(substr($name, 3)); 213 | if (property_exists($this, $property)) { 214 | $this->{$property} = $arguments[0]; 215 | return $this; 216 | } 217 | } 218 | 219 | if (str_starts_with($name, 'get')) { 220 | $property = lcfirst(substr($name, 3)); 221 | if (property_exists($this, $property)) { 222 | return $this->{$property}; 223 | } 224 | } 225 | 226 | throw new \BadMethodCallException("Method $name does not exist"); 227 | } 228 | 229 | } -------------------------------------------------------------------------------- /src/Middleware.php: -------------------------------------------------------------------------------- 1 | getQuery()) > 0) { 41 | if (!str_contains($uri, '?')) { 42 | $uri .= '?'; 43 | } 44 | $uri .= $options->getQueryString(); 45 | } 46 | 47 | curl_setopt($handler, CURLOPT_URL, $uri); 48 | 49 | self::set_curl_options($method, $handler, $options); 50 | 51 | return $handler; 52 | } 53 | 54 | /** 55 | * Setup curl options based on the given method and our options. 56 | * 57 | * @param \CurlHandle $cHandler 58 | * @param ?string $method 59 | * @param HttpOptions $options 60 | * 61 | * @return void 62 | */ 63 | public static function set_curl_options(?string $method, \CurlHandle $cHandler, HttpOptions $options): void 64 | { 65 | curl_setopt($cHandler, CURLOPT_HEADER, true); 66 | curl_setopt($cHandler, CURLOPT_CUSTOMREQUEST, $method ?? 'GET'); 67 | 68 | # Fetch the header 69 | $fetchedHeaders = []; 70 | foreach ($options->getHeaders() as $header => $value) { 71 | $fetchedHeaders[] = $header . ': ' . $value; 72 | } 73 | 74 | # Set headers 75 | curl_setopt($cHandler, CURLOPT_HTTPHEADER, $fetchedHeaders ?? []); 76 | 77 | # Add body if we have one. 78 | if ($options->getBody()) { 79 | curl_setopt($cHandler, CURLOPT_CUSTOMREQUEST, $method ?? 'POST'); 80 | curl_setopt($cHandler, CURLOPT_POSTFIELDS, $options->getBody()); 81 | curl_setopt($cHandler, CURLOPT_POST, true); 82 | } 83 | 84 | # Check for a proxy 85 | if ($options->getProxy() != null) { 86 | curl_setopt($cHandler, CURLOPT_PROXY, $options->getProxy()->getHost()); 87 | curl_setopt($cHandler, CURLOPT_PROXYUSERPWD, $options->getProxy()->getAuth()); 88 | if ($options->getProxy()->type !== null) { 89 | curl_setopt($cHandler, CURLOPT_PROXYTYPE, $options->getProxy()->type); 90 | } 91 | } 92 | 93 | curl_setopt($cHandler, CURLOPT_RETURNTRANSFER, true); 94 | curl_setopt($cHandler, CURLOPT_FOLLOWLOCATION, true); 95 | 96 | # Add and override the custom curl options. 97 | foreach ($options->getCurlOptions() as $option => $value) { 98 | curl_setopt($cHandler, $option, $value); 99 | } 100 | 101 | # if we have a timeout, set it. 102 | curl_setopt($cHandler, CURLOPT_TIMEOUT, $options->getTimeout()); 103 | 104 | # If self-signed certs are allowed, set it. 105 | if ((bool)getenv('HAS_SELF_SIGNED_CERT') === true) { 106 | curl_setopt($cHandler, CURLOPT_SSL_VERIFYPEER, false); 107 | curl_setopt($cHandler, CURLOPT_SSL_VERIFYHOST, false); 108 | } 109 | 110 | (new Middleware())->handle_media($cHandler, $options); 111 | } 112 | 113 | /** 114 | * Handle the media 115 | * 116 | * @param \CurlHandle $handler 117 | * @param HttpOptions $options 118 | * @return void 119 | */ 120 | private function handle_media(\CurlHandle $handler, HttpOptions $options): void 121 | { 122 | if (count($options->getMultipart()) > 0) { 123 | curl_setopt($handler, CURLOPT_POST, true); 124 | curl_setopt($handler, CURLOPT_CUSTOMREQUEST, 'POST'); 125 | 126 | $form_data = new FormData(); 127 | foreach ($options->getMultipart() as $key => $value) { 128 | $form_data->addFile($key, $value); 129 | } 130 | 131 | $headers = []; 132 | foreach ($options->getHeaders() as $header => $value) { 133 | if (Toolkit::insensitiveString($header, 'content-type')) continue; 134 | $headers[] = $header . ': ' . $value; 135 | } 136 | $headers[] = 'Content-Type: multipart/form-data'; 137 | 138 | curl_setopt($handler, CURLOPT_HTTPHEADER, $headers); 139 | curl_setopt($handler, CURLOPT_POSTFIELDS, $form_data->getFiles()); 140 | } 141 | } 142 | 143 | /** 144 | * @param mixed $socket 145 | * @param int $len 146 | * @return string|null 147 | * @throws ConnectionException 148 | */ 149 | public static function stream_read(mixed $socket, int $len): string|null 150 | { 151 | if (!is_resource($socket)) { 152 | throw new ConnectionException(sprintf( 153 | '%s is not a valid resource. Datatype: %s', $socket, gettype($socket) 154 | )); 155 | } 156 | 157 | $data = ''; 158 | while (($dataLen = strlen($data)) < $len) { 159 | $buff = fread($socket, $len - $dataLen); 160 | 161 | if ($buff === false) { 162 | return null; 163 | } 164 | 165 | if ($buff === '') { 166 | $metadata = stream_get_meta_data($socket); 167 | throw new ConnectionException( 168 | sprintf('Empty read; connection dead? Stream state: %s', json_encode($metadata)), 169 | CommonsContract::CLIENT_EMPTY_READ 170 | ); 171 | } 172 | $data .= $buff; 173 | } 174 | 175 | return $data; 176 | } 177 | 178 | /** 179 | * @param mixed $socket 180 | * @param string $data 181 | * @return bool 182 | * @throws ConnectionException 183 | */ 184 | public static function stream_write(mixed $socket, string $data): bool 185 | { 186 | if (!is_resource($socket)) { 187 | throw new ConnectionException(sprintf( 188 | '%s is not a valid resource. Datatype: %s', $socket, gettype($socket) 189 | )); 190 | } 191 | 192 | $written = fwrite($socket, $data); 193 | 194 | if ($written < strlen($data)) { 195 | throw new ConnectionException( 196 | sprintf('Could only write %s out of %s bytes.', $written, strlen($data)), 197 | CommonsContract::CLIENT_COULD_ONLY_WRITE_LESS 198 | ); 199 | } 200 | 201 | return true; 202 | } 203 | 204 | } -------------------------------------------------------------------------------- /src/Helpers/Functions.php: -------------------------------------------------------------------------------- 1 | generate($type); 20 | } 21 | } 22 | 23 | if (!function_exists('getFileExtension')) { 24 | /** 25 | * Get file extension. 26 | * 27 | * @param string $filename The absolute path to the file. 28 | * @return string eg. jpg 29 | */ 30 | function getFileExtension(string $filename): string 31 | { 32 | return pathinfo($filename, PATHINFO_EXTENSION); 33 | } 34 | } 35 | 36 | if (!function_exists('getFileMime')) { 37 | /** 38 | * Get filetype with the extension. 39 | * 40 | * @param string $filename The absolute path to the file. 41 | * @return string eg. image/jpeg 42 | */ 43 | function getFileMime(string $filename): string 44 | { 45 | $extension = getFileExtension($filename); 46 | return (new MimeTypes())->getMimeTypes($extension)[0] ?? 'application/octet-stream'; 47 | } 48 | } 49 | 50 | if (!function_exists('getFileSize')) { 51 | /** 52 | * Get file size. 53 | * 54 | * @param string $url The direct url to the file. 55 | * @return int 56 | */ 57 | function getFileSize(string $url): int 58 | { 59 | if (file_exists($url)) { 60 | return filesize($url); 61 | } 62 | 63 | $response = (new HttpClient())->get($url, [ 64 | 'headers' => [ 65 | 'User-Agent' => randomAgent(), 66 | 'Range' => 'bytes=0-1' 67 | ], 68 | 'CurlOptions' => [ 69 | CURLOPT_NOBODY => true, 70 | ] 71 | ]); 72 | 73 | return (int)$response->getHeaderLine('Content-Length') ?? 0; 74 | } 75 | } 76 | 77 | if (!function_exists('getChunkSize')) { 78 | /** 79 | * Get the size of each chunk. 80 | * 81 | * For default, we're dividing filesize to 10 as max size of each chunk. 82 | * If the file size was smaller than 2MB, we'll use the filesize as single chunk. 83 | * 84 | * @param int $fileSize The file size. 85 | * @param int $maxChunks The max number of chunks. (default: 10) 86 | * @return int 87 | */ 88 | function getChunkSize(int $fileSize, int $maxChunks = 10): int 89 | { 90 | $maxChunkSize = $fileSize / $maxChunks; 91 | 92 | if ($fileSize <= 2 * 1024 * 1024) { 93 | return $fileSize; 94 | } 95 | 96 | return min($maxChunkSize, $fileSize); 97 | } 98 | } 99 | 100 | if (!function_exists('downloadChunk')) { 101 | /** 102 | * Download a chunk of the file. 103 | * 104 | * @param string $url The direct url to the file. 105 | * @param int $start The start of the chunk. 106 | * @param int $end The end of the chunk. 107 | * @param array|HttpOptions $options The options to use. 108 | * 109 | * @return Chunk 110 | */ 111 | function downloadChunk(string $url, int $start, int $end, array|HttpOptions $options = []): Chunk 112 | { 113 | if (gettype($options) === 'array') { 114 | $options = new HttpOptions($options); 115 | } 116 | 117 | $chunk = (new Chunk())->setStartByte($start)->setEndByte($end); 118 | 119 | $response = (new HttpClient())->get($url, array_merge($options->toArray(), [ 120 | 'CurlOptions' => [ 121 | CURLOPT_RANGE => $start . '-' . $end 122 | ], 123 | ])); 124 | 125 | $chunk->setBody($response->getBody()); 126 | $micro = $response->getInfo()->getTotalTime(); 127 | $chunk->setElapsedTime($micro / 1000); 128 | 129 | return $chunk; 130 | } 131 | } 132 | 133 | if (!function_exists('download')) { 134 | /** 135 | * Download large files. 136 | * 137 | * This method is used to download large files with creating multiple requests. 138 | * 139 | * Change `max_chunk_count` variable to change the number of chunks. (default: 10) 140 | * 141 | * @param string $url The direct url to the file. 142 | * @param array|HttpOptions $options The options to use. 143 | * 144 | * @return DownloadResult 145 | */ 146 | function download(string $url, array|HttpOptions $options = []): DownloadResult 147 | { 148 | $tempDir = $options['temp_dir'] ?? $_SERVER['TEMP'] ?? null; 149 | $maxChunkCount = $options['max_chunk_count'] ?? 10; 150 | 151 | if (!$tempDir) { 152 | throw new \RuntimeException("The temp directory is not defined."); 153 | } 154 | 155 | if (gettype($options) === 'array') { 156 | $options = new HttpOptions($options); 157 | } 158 | 159 | $fileSize = getFileSize($url); 160 | $chunkSize = getChunkSize($fileSize); 161 | 162 | $result = new DownloadResult(); 163 | 164 | $result->id = uniqid(); 165 | $result->chunksPath = $tempDir . '/' . $result->id . '/'; 166 | mkdir($result->chunksPath, 0777, true); 167 | 168 | $result->fileSize = $fileSize; 169 | $result->chunkSize = $chunkSize; 170 | $result->numberOfChunks = (int)ceil($fileSize / $chunkSize); 171 | 172 | $result->startTime = microtime(true); 173 | 174 | $requests = []; 175 | for ($i = 0; $i < $result->numberOfChunks; $i++) { 176 | $range = $i * $chunkSize . '-' . ($i + 1) * $chunkSize; 177 | if ($i + 1 === $result->numberOfChunks) { 178 | $range = $i * $chunkSize . '-' . $fileSize; 179 | } 180 | $requests[] = [ 181 | 'method' => 'GET', 182 | 'uri' => $url, 183 | 'options' => array_merge($options->toArray(), [ 184 | 'CurlOptions' => [ 185 | CURLOPT_RANGE => $range 186 | ], 187 | ]) 188 | ]; 189 | } 190 | 191 | $client = new HttpClient(); 192 | 193 | foreach ($client->bulk($requests) as $response) { 194 | $chunk = new Chunk(); 195 | $chunk->setBody($response->getBody()); 196 | $micro = $response->getInfo()->getTotalTime(); 197 | $chunk->setElapsedTime($micro / 1000); 198 | 199 | $result->chunks[] = $chunk; 200 | } 201 | 202 | $result->endTime = microtime(true); 203 | 204 | return $result; 205 | } 206 | } 207 | 208 | if (!function_exists('upload')) { 209 | /** 210 | * Upload single or multiple files 211 | * 212 | * This method is sending file with request method of POST and 213 | * Content-Type of multipart/form-data. 214 | * 215 | * @param string $url The direct url to the file. 216 | * @param array $filePath The path to the file. 217 | * @param array|HttpOptions $options The options to use. 218 | * 219 | * @return HttpResponse 220 | */ 221 | function upload(string $url, array $filePath, array|HttpOptions $options = []): HttpResponse 222 | { 223 | if (gettype($options) === 'array') { 224 | $options = new HttpOptions($options); 225 | } 226 | 227 | $multipart = []; 228 | 229 | foreach ($filePath as $key => $file) { 230 | $multipart[$key] = new \CURLFile(realpath($file), getFileMime($file)); 231 | } 232 | 233 | $options->setMultipart($multipart); 234 | 235 | return (new HttpClient())->post($url, $options); 236 | } 237 | } -------------------------------------------------------------------------------- /src/WebSocketConfig.php: -------------------------------------------------------------------------------- 1 | timeout; 98 | } 99 | 100 | /** 101 | * @param int $timeout 102 | * @return WebSocketConfig 103 | */ 104 | public function setTimeout(int $timeout): WebSocketConfig 105 | { 106 | $this->timeout = $timeout; 107 | return $this; 108 | } 109 | 110 | /** 111 | * @return int 112 | */ 113 | public function getPingInterval(): int 114 | { 115 | return $this->pingInterval; 116 | } 117 | 118 | /** 119 | * @param int $int 120 | * @return $this 121 | */ 122 | public function setPingInterval(int $int): WebSocketConfig 123 | { 124 | $this->pingInterval = $int; 125 | return $this; 126 | } 127 | 128 | /** 129 | * @return array 130 | */ 131 | public function getHeaders(): array 132 | { 133 | return $this->headers; 134 | } 135 | 136 | /** 137 | * @param array $headers 138 | * @return WebSocketConfig 139 | */ 140 | public function setHeaders(array $headers): WebSocketConfig 141 | { 142 | $this->headers = $headers; 143 | return $this; 144 | } 145 | 146 | /** 147 | * @return int 148 | */ 149 | public function getFragmentSize(): int 150 | { 151 | return $this->fragmentSize; 152 | } 153 | 154 | /** 155 | * @param int $fragmentSize 156 | * @return WebSocketConfig 157 | */ 158 | public function setFragmentSize(int $fragmentSize): WebSocketConfig 159 | { 160 | $this->fragmentSize = $fragmentSize; 161 | return $this; 162 | } 163 | 164 | /** 165 | * @return mixed 166 | */ 167 | public function getContext(): mixed 168 | { 169 | return $this->context; 170 | } 171 | 172 | /** 173 | * @param mixed $context 174 | * @return WebSocketConfig 175 | */ 176 | public function setContext(mixed $context): WebSocketConfig 177 | { 178 | $this->context = $context; 179 | return $this; 180 | } 181 | 182 | /** 183 | * @return mixed 184 | */ 185 | public function getScheme(): string 186 | { 187 | return $this->scheme; 188 | } 189 | 190 | /** 191 | * @param string $scheme 192 | * @return WebSocketConfig 193 | */ 194 | public function setScheme(string $scheme): WebSocketConfig 195 | { 196 | $this->scheme = $scheme; 197 | return $this; 198 | } 199 | 200 | /** 201 | * @return string 202 | */ 203 | public function getHost(): string 204 | { 205 | return $this->host; 206 | } 207 | 208 | /** 209 | * @param string $host 210 | * @return WebSocketConfig 211 | */ 212 | public function setHost(string $host): WebSocketConfig 213 | { 214 | $this->host = $host; 215 | return $this; 216 | } 217 | 218 | /** 219 | * @return string 220 | */ 221 | public function getUser(): string 222 | { 223 | return $this->user; 224 | } 225 | 226 | /** 227 | * @param array $urlParts 228 | * @return WebSocketConfig 229 | */ 230 | public function setUser(array $urlParts): WebSocketConfig 231 | { 232 | $this->user = $urlParts['user'] ?? ''; 233 | return $this; 234 | } 235 | 236 | /** 237 | * @return string 238 | */ 239 | public function getPassword(): string 240 | { 241 | return $this->password; 242 | } 243 | 244 | /** 245 | * @param array $urlParts 246 | * @return WebSocketConfig 247 | */ 248 | public function setPassword(array $urlParts): WebSocketConfig 249 | { 250 | $this->password = $urlParts['pass'] ?? ''; 251 | return $this; 252 | } 253 | 254 | /** 255 | * @return string 256 | */ 257 | public function getPort(): string 258 | { 259 | return $this->port; 260 | } 261 | 262 | /** 263 | * @param array $urlParts 264 | * @return WebSocketConfig 265 | */ 266 | public function setPort(array $urlParts): WebSocketConfig 267 | { 268 | $this->port = $urlParts['port'] ?? ($this->scheme === 'wss' ? '443' : '80'); 269 | return $this; 270 | } 271 | 272 | /** 273 | * @return array 274 | */ 275 | public function getContextOptions(): array 276 | { 277 | return $this->contextOptions; 278 | } 279 | 280 | /** 281 | * @param array $contextOptions 282 | * @return WebSocketConfig 283 | */ 284 | public function setContextOptions(array $contextOptions): WebSocketConfig 285 | { 286 | $this->contextOptions = $contextOptions; 287 | return $this; 288 | } 289 | 290 | /** 291 | * @param string $ip 292 | * @param string $port 293 | * @return WebSocketConfig 294 | */ 295 | public function setProxy(string $ip, string $port): WebSocketConfig 296 | { 297 | $this->hasProxy = true; 298 | $this->proxyIp = $ip; 299 | $this->proxyPort = $port; 300 | 301 | return $this; 302 | } 303 | 304 | /** 305 | * @return bool 306 | */ 307 | public function hasProxy(): bool 308 | { 309 | return $this->hasProxy; 310 | } 311 | 312 | /** 313 | * @return string|null 314 | */ 315 | public function getProxyIp(): ?string 316 | { 317 | return $this->proxyIp; 318 | } 319 | 320 | /** 321 | * @return string|null 322 | */ 323 | public function getProxyPort(): ?string 324 | { 325 | return $this->proxyPort; 326 | } 327 | 328 | /** 329 | * @return string|null 330 | */ 331 | public function getProxyAuth(): ?string 332 | { 333 | return $this->proxyAuth; 334 | } 335 | 336 | /** 337 | * Sets auth for proxy 338 | * 339 | * @param string $userName 340 | * @param string $password 341 | * @return WebSocketConfig 342 | */ 343 | public function setProxyAuth(string $userName, string $password): WebSocketConfig 344 | { 345 | $this->proxyAuth = (empty($userName) === false && empty($password) === false) ? base64_encode($userName . ':' . $password) : null; 346 | return $this; 347 | } 348 | 349 | } 350 | -------------------------------------------------------------------------------- /src/WebSocket.php: -------------------------------------------------------------------------------- 1 | 0, 42 | CommonsContract::EVENT_TYPE_TEXT => 1, 43 | CommonsContract::EVENT_TYPE_BINARY => 2, 44 | CommonsContract::EVENT_TYPE_CLOSE => 8, 45 | CommonsContract::EVENT_TYPE_PING => 9, 46 | CommonsContract::EVENT_TYPE_PONG => 10, 47 | ]; 48 | 49 | /** 50 | * @var WebSocketConfig 51 | */ 52 | protected WebSocketConfig $config; 53 | 54 | /** 55 | * @var string 56 | */ 57 | protected string $socketUrl; 58 | 59 | /** 60 | * @var resource|bool 61 | */ 62 | private $socket; 63 | 64 | /** 65 | * @var string 66 | */ 67 | private string $lastOpcode; 68 | 69 | /** 70 | * @var float|int 71 | */ 72 | private float|int $closeStatus; 73 | 74 | /** 75 | * @var string|null 76 | */ 77 | private ?string $hugePayload; 78 | 79 | /** 80 | * Sets parameters for Web Socket Client intercommunication 81 | * 82 | * @param ?SocketClient $client leave it empty if you want to use default socket client 83 | */ 84 | public function __construct(?SocketClient $client = null) 85 | { 86 | if ($client instanceof SocketClient) { 87 | 88 | $this->on('open', function ($socket) use ($client) { 89 | $client->onOpen($socket); 90 | }); 91 | 92 | $this->on('close', function ($socket, int $closeStatus) use ($client) { 93 | $client->onClose($socket, $closeStatus); 94 | }); 95 | 96 | $this->on('error', function ($socket, WebSocketException $exception) use ($client) { 97 | $client->onError($socket, $exception); 98 | }); 99 | 100 | $this->on('message', function ($socket, string $message) use ($client) { 101 | $client->onMessage($socket, $message); 102 | }); 103 | 104 | $this->on('meantime', function ($socket) use ($client) { 105 | $client->onMeantime($socket); 106 | }); 107 | } 108 | 109 | $this->config = $config ?? new WebSocketConfig(); 110 | } 111 | 112 | /** 113 | * @param int $timeout 114 | * @param null $microSecs 115 | * 116 | * @return void 117 | */ 118 | public function setTimeout(int $timeout, $microSecs = null): void 119 | { 120 | $this->config->setTimeout($timeout); 121 | if ($this->socket && get_resource_type($this->socket) === 'stream') { 122 | stream_set_timeout($this->socket, $timeout, $microSecs ?? 0); 123 | } 124 | } 125 | 126 | /** 127 | * @param string $name 128 | * @param array $arguments 129 | * 130 | * @return mixed 131 | * @throws \Exception 132 | */ 133 | public function __call(string $name, array $arguments): mixed 134 | { 135 | if (property_exists($this, $name)) { 136 | return $this->$name; 137 | } 138 | 139 | if (method_exists($this, $name)) { 140 | return call_user_func_array([$this, $name], $arguments); 141 | } 142 | 143 | if (str_starts_with($name, 'get')) { 144 | $property = lcfirst(substr($name, 3)); 145 | 146 | if (property_exists($this, $property)) { 147 | return $this->{$property}; 148 | } 149 | } 150 | 151 | if (str_starts_with($name, 'set')) { 152 | $property = lcfirst(substr($name, 3)); 153 | if (property_exists($this, $property)) { 154 | $this->{$property} = $arguments[0]; 155 | return $this; 156 | } 157 | } 158 | 159 | throw new \Exception(sprintf("Method '%s' does not exist.", $name)); 160 | } 161 | 162 | /** 163 | * Init a proxy connection 164 | * 165 | * @return resource|false 166 | * @throws \InvalidArgumentException 167 | * @throws ConnectionException 168 | */ 169 | private function proxy() 170 | { 171 | $sock = @stream_socket_client( 172 | WscCommonsContract::TCP_SCHEME . $this->config->getProxyIp() . ':' . $this->config->getProxyPort(), 173 | $errno, 174 | $errstr, 175 | $this->config->getTimeout(), 176 | STREAM_CLIENT_CONNECT, 177 | $this->getStreamContext() 178 | ); 179 | 180 | $write = "CONNECT {$this->config->getProxyIp()}:{$this->config->getProxyPort()} HTTP/1.1\r\n"; 181 | $auth = $this->config->getProxyAuth(); 182 | 183 | if ($auth !== NULL) { 184 | $write .= "Proxy-Authorization: Basic {$auth}\r\n"; 185 | } 186 | 187 | $write .= "\r\n"; 188 | fwrite($sock, $write); 189 | $resp = fread($sock, 1024); 190 | 191 | if (preg_match(self::PROXY_MATCH_RESP, $resp) === 1) { 192 | return $sock; 193 | } 194 | 195 | throw new ConnectionException('Failed to connect to the host via proxy'); 196 | } 197 | 198 | /** 199 | * @return mixed 200 | * @throws \InvalidArgumentException 201 | */ 202 | private function getStreamContext(): mixed 203 | { 204 | if ($this->config->getContext() !== null) { 205 | // Suppress the error since we'll catch it below 206 | if (@get_resource_type($this->config->getContext()) === 'stream-context') { 207 | return $this->config->getContext(); 208 | } 209 | 210 | throw new \InvalidArgumentException( 211 | 'Stream context is invalid', 212 | CommonsContract::CLIENT_INVALID_STREAM_CONTEXT 213 | ); 214 | } 215 | 216 | return stream_context_create($this->config->getContextOptions()); 217 | } 218 | 219 | /** 220 | * @param mixed $urlParts 221 | * 222 | * @return string 223 | */ 224 | private function getPathWithQuery(mixed $urlParts): string 225 | { 226 | $path = $urlParts['path'] ?? '/'; 227 | $query = $urlParts['query'] ?? ''; 228 | $fragment = $urlParts['fragment'] ?? ''; 229 | $pathWithQuery = $path; 230 | 231 | if (!empty($query)) { 232 | $pathWithQuery .= '?' . $query; 233 | } 234 | 235 | if (!empty($fragment)) { 236 | $pathWithQuery .= '#' . $fragment; 237 | } 238 | 239 | return $pathWithQuery; 240 | } 241 | 242 | /** 243 | * @param string $pathWithQuery 244 | * @param array $headers 245 | * 246 | * @return string 247 | */ 248 | private function getHeaders(string $pathWithQuery, array $headers): string 249 | { 250 | return 'GET ' . $pathWithQuery . " HTTP/1.1\r\n" 251 | . implode( 252 | "\r\n", 253 | array_map( 254 | function ($key, $value) { 255 | return "$key: $value"; 256 | }, 257 | array_keys($headers), 258 | $headers 259 | ) 260 | ) 261 | . "\r\n\r\n"; 262 | } 263 | 264 | } -------------------------------------------------------------------------------- /src/Traits/WSConnectionTrait.php: -------------------------------------------------------------------------------- 1 | 'Upgrade', 58 | 'Upgrade' => 'WebSocket', 59 | 'Sec-Websocket-Version' => '13', 60 | ]; 61 | 62 | /** 63 | * Reconnect to the Web Socket server 64 | * 65 | * @return void 66 | * @throws \Exception 67 | */ 68 | public function reconnect(): void 69 | { 70 | if ($this->isConnected) { 71 | $this->close(); 72 | } 73 | 74 | $this->connect($this->socketUrl, $this->config); 75 | } 76 | 77 | /** 78 | * Tell the socket to close. 79 | * 80 | * @param integer $status https://github.com/Luka967/websocket-close-codes 81 | * @param string $message A closing message, max 125 bytes. 82 | * @return bool|null|string 83 | * @throws \Exception 84 | */ 85 | public function close(int $status = 1000, string $message = 'ttfn'): bool|null|string 86 | { 87 | $statusBin = sprintf('%016b', $status); 88 | $statusStr = ''; 89 | 90 | foreach (str_split($statusBin, 8) as $binstr) { 91 | $statusStr .= chr(bindec($binstr)); 92 | } 93 | 94 | $this->send($statusStr . $message, CommonsContract::EVENT_TYPE_CLOSE); 95 | $this->closeStatus = $status; 96 | $this->isClosing = true; 97 | 98 | return $this->receive(); // Receiving a close frame will close the socket now. 99 | } 100 | 101 | /** 102 | * Sends message to opened socket connection client->server 103 | * 104 | * @param $payload 105 | * @param string $opcode 106 | */ 107 | public function send($payload, string $opcode = CommonsContract::EVENT_TYPE_TEXT): void 108 | { 109 | if (!$this->isConnected) { 110 | throw new WebSocketException( 111 | "Can't send message. Connection is not established.", 112 | CommonsContract::CLIENT_CONNECTION_NOT_ESTABLISHED 113 | ); 114 | } 115 | 116 | if (array_key_exists($opcode, self::$opcodes) === false) { 117 | throw new BadOpcodeException( 118 | sprintf("Bad opcode '%s'. Try 'text' or 'binary'.", $opcode), 119 | CommonsContract::CLIENT_BAD_OPCODE 120 | ); 121 | } 122 | 123 | $payloadLength = strlen($payload); 124 | $fragmentCursor = 0; 125 | 126 | while ($payloadLength > $fragmentCursor) { 127 | $subPayload = substr($payload, $fragmentCursor, $this->config->getFragmentSize()); 128 | $fragmentCursor += $this->config->getFragmentSize(); 129 | $final = $payloadLength <= $fragmentCursor; 130 | $this->sendFragment($final, $subPayload, $opcode, true); 131 | $opcode = 'continuation'; 132 | } 133 | } 134 | 135 | /** 136 | * Receives message client<-server 137 | * 138 | * @return string|null 139 | * @throws ConnectionException 140 | */ 141 | public function receive(): string|null 142 | { 143 | if (!$this->isConnected && $this->isClosing === false) { 144 | throw new WebSocketException( 145 | "Your unexpectedly disconnected from the server", 146 | CommonsContract::CLIENT_CONNECTION_NOT_ESTABLISHED 147 | ); 148 | } 149 | 150 | $this->hugePayload = ''; 151 | 152 | return $this->receiveFragment(); 153 | } 154 | 155 | /** 156 | * @param string $socketUrl string that represents the URL of the Web Socket server. e.g. ws://localhost:1337 or wss://localhost:1337 157 | * @param ?WebSocketConfig $config The configuration for the Web Socket client 158 | * @throws ConnectionException 159 | */ 160 | public function connect(string $socketUrl, ?WebSocketConfig $config = null): void 161 | { 162 | $this->config = $config ?? new WebSocketConfig(); 163 | $this->socketUrl = $socketUrl; 164 | $urlParts = parse_url($this->socketUrl); 165 | 166 | $this->config->setScheme($urlParts['scheme']); 167 | $this->config->setHost($urlParts['host']); 168 | $this->config->setUser($urlParts); 169 | $this->config->setPassword($urlParts); 170 | $this->config->setPort($urlParts); 171 | 172 | $pathWithQuery = $this->getPathWithQuery($urlParts); 173 | $hostUri = $this->getHostUri($this->config); 174 | 175 | $context = $this->getStreamContext(); 176 | if ($this->config->hasProxy()) { 177 | $this->socket = $this->proxy(); 178 | } else { 179 | $this->socket = @stream_socket_client( 180 | $hostUri . ':' . $this->config->getPort(), 181 | $errno, 182 | $errstr, 183 | $this->config->getTimeout(), 184 | STREAM_CLIENT_CONNECT, 185 | $context 186 | ); 187 | } 188 | 189 | if ($this->socket === false) { 190 | throw new ConnectionException( 191 | "Could not open socket to \"{$this->config->getHost()}:{$this->config->getPort()}\": $errstr ($errno).", 192 | CommonsContract::CLIENT_COULD_NOT_OPEN_SOCKET 193 | ); 194 | } 195 | 196 | stream_set_timeout($this->socket, $this->config->getTimeout()); 197 | 198 | $key = $this->generateKey(); 199 | $headers = array_merge($this->defaultHeaders, [ 200 | 'Host' => $this->config->getHost() . ':' . $this->config->getPort(), 201 | 'User-Agent' => 'Easy-Http/' . self::VERSION . ' (PHP/' . PHP_VERSION . ')', 202 | 'Sec-WebSocket-Key' => $key, 203 | ]); 204 | 205 | if ($this->config->getUser() || $this->config->getPassword()) { 206 | $headers['authorization'] = 'Basic ' . base64_encode($this->config->getUser() . ':' . $this->config->getPassword()) . "\r\n"; 207 | } 208 | 209 | if (!empty($this->config->getHeaders())) { 210 | $headers = array_merge($headers, $this->config->getHeaders()); 211 | } 212 | 213 | $header = $this->getHeaders($pathWithQuery, $headers); 214 | 215 | $this->write($header); 216 | 217 | $this->validateResponse($this->config, $pathWithQuery, $key); 218 | $this->isConnected = true; 219 | $this->whileIsConnected(); 220 | } 221 | 222 | /** 223 | * @return void 224 | */ 225 | private function whileIsConnected(): void 226 | { 227 | $this->safeCall('open', $this); 228 | 229 | while ($this->isConnected() && $this->isClosing === false) { 230 | $this->safeCall('meantime', $this); 231 | 232 | if (is_string(($message = $this->receive()))) { 233 | $this->safeCall('message', $this, $message); 234 | } 235 | } 236 | 237 | $this->safeCall('close', $this, $this->closeStatus); 238 | } 239 | 240 | /** 241 | * Execute events with safety of exceptions 242 | * 243 | * @param string $type The type of event to execute 244 | * @param mixed ...$args 245 | * @return void 246 | */ 247 | private function safeCall(string $type, ...$args): void 248 | { 249 | if (isset($this->registeredEvents[$type])) { 250 | 251 | if (is_callable($this->registeredEvents[$type])) { 252 | call_user_func_array($this->registeredEvents[$type], $args); 253 | return; 254 | } 255 | 256 | throw new WebSocketException(sprintf( 257 | "The event '%s' is not callable.", $type 258 | ), CommonsContract::CLIENT_EVENT_NOT_CALLABLE); 259 | } 260 | } 261 | 262 | /** 263 | * Register a event listener 264 | * 265 | * @param string $event 266 | * @param callable $callback 267 | * 268 | * @return void 269 | */ 270 | public function on(string $event, callable $callback): void 271 | { 272 | if (!in_array($event, $this->allowedEvents)) { 273 | throw new \RuntimeException("Event {$event} not allowed"); 274 | } 275 | 276 | $this->registeredEvents[$event] = $callback; 277 | } 278 | 279 | } -------------------------------------------------------------------------------- /src/Traits/WSClientTrait.php: -------------------------------------------------------------------------------- 1 | read(2); 29 | if (is_string($data) === false) { 30 | return null; 31 | } 32 | 33 | $final = (bool)(ord($data[0]) & 1 << 7); 34 | 35 | $opcodeInt = ord($data[0]) & 31; 36 | $opcodeInts = array_flip(self::$opcodes); 37 | if (!array_key_exists($opcodeInt, $opcodeInts)) { 38 | throw new ConnectionException( 39 | "Bad opcode in websocket frame: $opcodeInt", 40 | CommonsContract::CLIENT_BAD_OPCODE 41 | ); 42 | } 43 | 44 | $opcode = $opcodeInts[$opcodeInt]; 45 | 46 | if ($opcode !== 'continuation') { 47 | $this->lastOpcode = $opcode; 48 | } 49 | 50 | $payloadLength = $this->getPayloadLength($data); 51 | $payload = $this->getPayloadData($data, $payloadLength); 52 | 53 | if ($opcode === CommonsContract::EVENT_TYPE_CLOSE) { 54 | if ($payloadLength >= 2) { 55 | $statusBin = $payload[0] . $payload[1]; 56 | $status = bindec(sprintf('%08b%08b', ord($payload[0]), ord($payload[1]))); 57 | $this->closeStatus = $status; 58 | $payload = substr($payload, 2); 59 | 60 | if (!$this->isClosing) { 61 | $this->send($statusBin . 'Close acknowledged: ' . $status, 62 | CommonsContract::EVENT_TYPE_CLOSE); // Respond. 63 | } 64 | } 65 | 66 | if ($this->isClosing) { 67 | $this->isClosing = false; // A close response, all done. 68 | } 69 | 70 | fclose($this->socket); 71 | $this->isConnected = false; 72 | } 73 | 74 | if (!$final) { 75 | $this->hugePayload .= $payload; 76 | 77 | return null; 78 | } 79 | 80 | if ($this->hugePayload) { 81 | $payload = $this->hugePayload .= $payload; 82 | $this->hugePayload = null; 83 | } 84 | 85 | return $payload; 86 | } 87 | 88 | /** 89 | * @param int $len 90 | * @return string|null 91 | * @throws ConnectionException 92 | */ 93 | protected function read(int $len): string|null 94 | { 95 | if ($this->socket && $this->isConnected()) { 96 | return Middleware::stream_read($this->socket, $len); 97 | } 98 | 99 | return null; 100 | } 101 | 102 | /** 103 | * @param string $data 104 | * @return float|int 105 | * @throws ConnectionException 106 | */ 107 | private function getPayloadLength(string $data): float|int 108 | { 109 | $payloadLength = (int)ord($data[1]) & self::MASK_127; // Bits 1-7 in byte 1 110 | if ($payloadLength > self::MASK_125) { 111 | if ($payloadLength === self::MASK_126) { 112 | $data = $this->read(2); // 126: Payload is a 16-bit unsigned int 113 | } else { 114 | $data = $this->read(8); // 127: Payload is a 64-bit unsigned int 115 | } 116 | $payloadLength = bindec(Toolkit::sprintB($data)); 117 | } 118 | 119 | return $payloadLength; 120 | } 121 | 122 | /** 123 | * @param string $data 124 | * @param int $payloadLength 125 | * @return string 126 | * @throws ConnectionException 127 | */ 128 | private function getPayloadData(string $data, int $payloadLength): string 129 | { 130 | // Masking? 131 | $mask = (bool)(ord($data[1]) >> 7); // Bit 0 in byte 1 132 | $payload = ''; 133 | $maskingKey = ''; 134 | 135 | // Get masking key. 136 | if ($mask) { 137 | $maskingKey = $this->read(4); 138 | } 139 | 140 | // Get the actual payload, if any (might not be for e.g. close frames. 141 | if ($payloadLength > 0) { 142 | $data = $this->read($payloadLength); 143 | 144 | if ($mask) { 145 | // Unmask payload. 146 | for ($i = 0; $i < $payloadLength; $i++) { 147 | $payload .= ($data[$i] ^ $maskingKey[$i % 4]); 148 | } 149 | } else { 150 | $payload = $data; 151 | } 152 | } 153 | 154 | return $payload; 155 | } 156 | 157 | /** 158 | * @param $final 159 | * @param $payload 160 | * @param $opcode 161 | * @param $masked 162 | * @return void 163 | */ 164 | protected function sendFragment($final, $payload, $opcode, $masked): void 165 | { 166 | // Binary string for header. 167 | $frameHeadBin = ''; 168 | // Write FIN, final fragment bit. 169 | $frameHeadBin .= (bool)$final ? '1' : '0'; 170 | // RSV 1, 2, & 3 false and unused. 171 | $frameHeadBin .= '000'; 172 | // Opcode rest of the byte. 173 | $frameHeadBin .= sprintf('%04b', self::$opcodes[$opcode]); 174 | // Use masking? 175 | $frameHeadBin .= $masked ? '1' : '0'; 176 | 177 | // 7 bits of payload length... 178 | $payloadLen = strlen($payload); 179 | if ($payloadLen > self::MAX_BYTES_READ) { 180 | $frameHeadBin .= decbin(self::MASK_127); 181 | $frameHeadBin .= sprintf('%064b', $payloadLen); 182 | } else if ($payloadLen > self::MASK_125) { 183 | $frameHeadBin .= decbin(self::MASK_126); 184 | $frameHeadBin .= sprintf('%016b', $payloadLen); 185 | } else { 186 | $frameHeadBin .= sprintf('%07b', $payloadLen); 187 | } 188 | 189 | $frame = ''; 190 | 191 | // Write frame head to frame. 192 | foreach (str_split($frameHeadBin, 8) as $binstr) { 193 | $frame .= chr(bindec($binstr)); 194 | } 195 | // Handle masking 196 | if ($masked) { 197 | // generate a random mask: 198 | $mask = ''; 199 | for ($i = 0; $i < 4; $i++) { 200 | $mask .= chr(rand(0, 255)); 201 | } 202 | $frame .= $mask; 203 | } 204 | 205 | // Append payload to frame: 206 | for ($i = 0; $i < $payloadLen; $i++) { 207 | $frame .= ($masked === true) ? $payload[$i] ^ $mask[$i % 4] : $payload[$i]; 208 | } 209 | 210 | $this->write($frame); 211 | } 212 | 213 | /** 214 | * @param string $data 215 | * @return void 216 | * @throws ConnectionException 217 | */ 218 | protected function write(string $data): void 219 | { 220 | Middleware::stream_write($this->socket, $data); 221 | } 222 | 223 | /** 224 | * Validates whether server sent valid upgrade response 225 | * 226 | * @param WebSocketConfig $config 227 | * @param string $pathWithQuery 228 | * @param string $key 229 | * @throws ConnectionException 230 | */ 231 | private function validateResponse(WebSocketConfig $config, string $pathWithQuery, string $key): void 232 | { 233 | $response = stream_get_line($this->socket, self::DEFAULT_RESPONSE_HEADER, "\r\n\r\n"); 234 | if (!preg_match(self::SEC_WEBSOCKET_ACCEPT_PTTRN, $response, $matches)) { 235 | $address = $config->getScheme() . '://' . $config->getHost() . ':' . $config->getPort() . $pathWithQuery; 236 | throw new ConnectionException( 237 | "Connection to '{$address}' failed: Server sent invalid upgrade response:\n" 238 | . $response, CommonsContract::CLIENT_INVALID_UPGRADE_RESPONSE 239 | ); 240 | } 241 | 242 | $keyAccept = trim($matches[1]); 243 | $expectedResponse = base64_encode(pack('H*', sha1($key . self::SERVER_KEY_ACCEPT))); 244 | if ($keyAccept !== $expectedResponse) { 245 | throw new ConnectionException( 246 | 'Server sent bad upgrade response.', 247 | CommonsContract::CLIENT_INVALID_UPGRADE_RESPONSE 248 | ); 249 | } 250 | } 251 | 252 | /** 253 | * Gets host uri based on protocol 254 | * 255 | * @param WebSocketConfig $config 256 | * @return string 257 | * @throws BadUriException 258 | */ 259 | private function getHostUri(WebSocketConfig $config): string 260 | { 261 | if (in_array($config->getScheme(), ['ws', 'wss'], true) === false) { 262 | throw new BadUriException( 263 | "Url should have scheme ws or wss, not '{$config->getScheme()}' from URI '$this->socketUrl' .", 264 | CommonsContract::CLIENT_INCORRECT_SCHEME 265 | ); 266 | } 267 | 268 | return ($config->getScheme() === 'wss' ? 'ssl' : 'tcp') . '://' . $config->getHost(); 269 | } 270 | 271 | /** 272 | * Sec-WebSocket-Key generator 273 | * 274 | * @return string the 16 character length key 275 | */ 276 | private function generateKey(): string 277 | { 278 | $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"$&/()=[]{}0123456789'; 279 | $key = ''; 280 | $chLen = strlen($chars); 281 | for ($i = 0; $i < self::KEY_GEN_LENGTH; $i++) { 282 | $key .= $chars[rand(0, $chLen - 1)]; 283 | } 284 | 285 | return base64_encode($key); 286 | } 287 | 288 | } -------------------------------------------------------------------------------- /src/AgentGenerator.php: -------------------------------------------------------------------------------- 1 | 10 | * @license MIT 11 | */ 12 | class AgentGenerator 13 | { 14 | 15 | /** 16 | * Windows Operating System list with dynamic versioning 17 | * 18 | * @var array $windows_os 19 | */ 20 | public array $windows_os = [ 21 | '[Windows; |Windows; U; |]Windows NT 6.:number0-3:;[ Win64; x64| WOW64| x64|]', 22 | '[Windows; |Windows; U; |]Windows NT 10.:number0-5:;[ Win64; x64| WOW64| x64|]' 23 | ]; 24 | 25 | /** 26 | * Linux Operating Systems [limited] 27 | * 28 | * @var array $linux_os 29 | */ 30 | public array $linux_os = [ 31 | '[Linux; |][U; |]Linux x86_64', 32 | '[Linux; |][U; |]Linux i:number5-6::number4-8::number0-6: [x86_64|]' 33 | ]; 34 | 35 | /** 36 | * Mac Operating System (OS X) with dynamic versioning 37 | * 38 | * @var array $mac_os 39 | */ 40 | public array $mac_os = [ 41 | 'Macintosh; [U; |]Intel Mac OS X :number7-9:_:number0-9:_:number0-9:', 42 | 'Macintosh; [U; |]Intel Mac OS X 10_:number0-12:_:number0-9:' 43 | ]; 44 | 45 | /** 46 | * Versions of Android to be used 47 | * 48 | * @var array $androidVersions 49 | */ 50 | public array $androidVersions = [ 51 | '4.3.1', 52 | '4.4', 53 | '4.4.1', 54 | '4.4.4', 55 | '5.0', 56 | '5.0.1', 57 | '5.0.2', 58 | '5.1', 59 | '5.1.1', 60 | '6.0', 61 | '6.0.1', 62 | '7.0', 63 | '7.1', 64 | '7.1.1' 65 | ]; 66 | 67 | /** 68 | * Holds the version of android for the User Agent being generated 69 | * 70 | * @property string $androidVersion 71 | */ 72 | public string $androidVersion; 73 | 74 | /** 75 | * Android devices and for specific android versions 76 | * 77 | * @var array $androidDevices 78 | */ 79 | public array $androidDevices = [ 80 | '4.3' => [ 81 | 'GT-I9:number2-5:00 Build/JDQ39', 82 | 'Nokia 3:number1-3:[10|15] Build/IMM76D', 83 | '[SAMSUNG |]SM-G3:number1-5:0[R5|I|V|A|T|S] Build/JLS36C', 84 | 'Ascend G3:number0-3:0 Build/JLS36I', 85 | '[SAMSUNG |]SM-G3:number3-6::number1-8::number0-9:[V|A|T|S|I|R5] Build/JLS36C', 86 | 'HUAWEI G6-L:number10-11: Build/HuaweiG6-L:number10-11:', 87 | '[SAMSUNG |]SM-[G|N]:number7-9:1:number0-8:[S|A|V|T] Build/[JLS36C|JSS15J]', 88 | '[SAMSUNG |]SGH-N0:number6-9:5[T|V|A|S] Build/JSS15J', 89 | 'Samsung Galaxy S[4|IV] Mega GT-I:number89-95:00 Build/JDQ39', 90 | 'SAMSUNG SM-T:number24-28:5[s|a|t|v] Build/[JLS36C|JSS15J]', 91 | 'HP :number63-73:5 Notebook PC Build/[JLS36C|JSS15J]', 92 | 'HP Compaq 2:number1-3:10b Build/[JLS36C|JSS15J]', 93 | 'HTC One 801[s|e] Build/[JLS36C|JSS15J]', 94 | 'HTC One max Build/[JLS36C|JSS15J]', 95 | 'HTC Xplorer A:number28-34:0[e|s] Build/GRJ90' 96 | ], 97 | '4.4' => [ 98 | 'XT10:number5-8:0 Build/SU6-7.3', 99 | 'XT10:number12-52: Build/[KXB20.9|KXC21.5]', 100 | 'Nokia :number30-34:10 Build/IMM76D', 101 | 'E:number:20-23::number0-3::number0-4: Build/24.0.[A|B].1.34', 102 | '[SAMSUNG |]SM-E500[F|L] Build/KTU84P', 103 | 'LG Optimus G Build/KRT16M', 104 | 'LG-E98:number7-9: Build/KOT49I', 105 | 'Elephone P:number2-6:000 Build/KTU84P', 106 | 'IQ450:number0-4: Quad Build/KOT49H', 107 | 'LG-F:number2-5:00[K|S|L] Build/KOT49[I|H]', 108 | 'LG-V:number3-7::number0-1:0 Build/KOT49I', 109 | '[SAMSUNG |]SM-J:number1-2::number0-1:0[G|F] Build/KTU84P', 110 | '[SAMSUNG |]SM-N80:number0-1:0 Build/[KVT49L|JZO54K]', 111 | '[SAMSUNG |]SM-N900:number5-8: Build/KOT49H', 112 | '[SAMSUNG-|]SGH-I337[|M] Build/[JSS15J|KOT49H]', 113 | '[SAMSUNG |]SM-G900[W8|9D|FD|H|V|FG|A|T] Build/KOT49H', 114 | '[SAMSUNG |]SM-T5:number30-35: Build/[KOT49H|KTU84P]', 115 | '[Google |]Nexus :number5-7: Build/KOT49H', 116 | 'LG-H2:number0-2:0 Build/KOT49[I|H]', 117 | 'HTC One[_M8|_M9|0P6B|801e|809d|0P8B2|mini 2|S][ dual sim|] Build/[KOT49H|KTU84L]', 118 | '[SAMSUNG |]GT-I9:number3-5:0:number0-6:[V|I|T|N] Build/KOT49H', 119 | 'Lenovo P7:number7-8::number1-6: Build/[Lenovo|JRO03C]', 120 | 'LG-D95:number1-8: Build/KOT49[I|H]', 121 | 'LG-D:number1-8::number0-8:0 Build/KOT49[I|H]', 122 | 'Nexus5 V:number6-7:.1 Build/KOT49H', 123 | 'Nexus[_|] :number4-10: Build/[KOT49H|KTU84P]', 124 | 'Nexus[_S_| S ][4G |]Build/GRJ22', 125 | '[HM NOTE|NOTE-III|NOTE2 1LTE[TD|W|T]', 126 | 'ALCATEL ONE[| ]TOUCH 70:number2-4::number0-9:[X|D|E|A] Build/KOT49H', 127 | 'MOTOROLA [MOTOG|MSM8960|RAZR] Build/KVT49L' 128 | ], 129 | '5.0' => [ 130 | 'Nokia :number10-11:00 [wifi|4G|LTE] Build/GRK39F', 131 | 'HTC 80:number1-2[s|w|e|t] Build/[LRX22G|JSS15J]', 132 | 'Lenovo A7000-a Build/LRX21M;', 133 | 'HTC Butterfly S [901|919][s|d|] Build/LRX22G', 134 | 'HTC [M8|M9|M8 Pro Build/LRX22G', 135 | 'LG-D3:number25-37: Build/LRX22G', 136 | 'LG-D72:number0-9: Build/LRX22G', 137 | '[SAMSUNG |]SM-G4:number0-9:0 Build/LRX22[G|C]', 138 | '[|SAMSUNG ]SM-G9[00|25|20][FD|8|F|F-ORANGE|FG|FQ|H|I|L|M|S|T] Build/[LRX21T|KTU84F|KOT49H]', 139 | '[SAMSUNG |]SM-A:number7-8:00[F|I|T|H|] Build/[LRX22G|LMY47X]', 140 | '[SAMSUNG-|]SM-N91[0|5][A|V|F|G|FY] Build/LRX22C', 141 | '[SAMSUNG |]SM-[T|P][350|550|555|355|805|800|710|810|815] Build/LRX22G', 142 | 'LG-D7:number0-2::number0-9: Build/LRX22G', 143 | '[LG|SM]-[D|G]:number8-9::number0-5::number0-9:[|P|K|T|I|F|T1] Build/[LRX22G|KOT49I|KVT49L|LMY47X]' 144 | ], 145 | '5.1' => [ 146 | 'Nexus :number5-9: Build/[LMY48B|LRX22C]', 147 | '[|SAMSUNG ]SM-G9[28|25|20][X|FD|8|F|F-ORANGE|FG|FQ|H|I|L|M|S|T] Build/[LRX22G|LMY47X]', 148 | '[|SAMSUNG ]SM-G9[35|350][X|FD|8|F|F-ORANGE|FG|FQ|H|I|L|M|S|T] Build/[MMB29M|LMY47X]', 149 | '[MOTOROLA |][MOTO G|MOTO G XT1068|XT1021|MOTO E XT1021|MOTO XT1580|MOTO X FORCE XT1580|MOTO X PLAY XT1562|MOTO XT1562|MOTO XT1575|MOTO X PURE XT1575|MOTO XT1570 MOTO X STYLE] Build/[LXB22|LMY47Z|LPC23|LPK23|LPD23|LPH223]' 150 | ], 151 | '6.0' => [ 152 | '[SAMSUNG |]SM-[G|D][920|925|928|9350][V|F|I|L|M|S|8|I] Build/[MMB29K|MMB29V|MDB08I|MDB08L]', 153 | 'Nexus :number5-7:[P|X|] Build/[MMB29K|MMB29V|MDB08I|MDB08L]', 154 | 'HTC One[_| ][M9|M8|M8 Pro] Build/MRA58K', 155 | 'HTC One[_M8|_M9|0P6B|801e|809d|0P8B2|mini 2|S][ dual sim|] Build/MRA58K' 156 | ], 157 | '7.0' => [ 158 | 'Pixel [XL|C] Build/[NRD90M|NME91E]', 159 | 'Nexus :number5-9:[X|P|] Build/[NPD90G|NME91E]', 160 | '[SAMSUNG |]GT-I:number91-98:00 Build/KTU84P', 161 | 'Xperia [V |]Build/NDE63X', 162 | 'LG-H:number90-93:0 Build/NRD90[C|M]' 163 | ], 164 | '7.1' => [ 165 | 'Pixel [XL|C] Build/[NRD90M|NME91E]', 166 | 'Nexus :number5-9:[X|P|] Build/[NPD90G|NME91E]', 167 | '[SAMSUNG |]GT-I:number91-98:00 Build/KTU84P', 168 | 'Xperia [V |]Build/NDE63X', 169 | 'LG-H:number90-93:0 Build/NRD90[C|M]' 170 | ] 171 | ]; 172 | 173 | /** 174 | * List of "OS" strings used for android 175 | * 176 | * @var array $android_os 177 | */ 178 | public array $android_os = [ 179 | 'Linux; Android :androidVersion:; :androidDevice:', 180 | //TODO: Add a $windowsDevices variable that does the same as androidDevice 181 | //'Windows Phone 10.0; Android :androidVersion:; :windowsDevice:', 182 | 'Linux; U; Android :androidVersion:; :androidDevice:', 183 | 'Android; Android :androidVersion:; :androidDevice:' 184 | ]; 185 | 186 | /** 187 | * List of "OS" strings used for iOS 188 | * 189 | * @var array $mobile_ios 190 | */ 191 | public array $mobile_ios = [ 192 | 'iphone' => 'iPhone; CPU iPhone OS :number7-11:_:number0-9:_:number0-9:; like Mac OS X;', 193 | 'ipad' => 'iPad; CPU iPad OS :number7-11:_:number0-9:_:number0-9: like Mac OS X;', 194 | 'ipod' => 'iPod; CPU iPod OS :number7-11:_:number0-9:_:number0-9:; like Mac OS X;' 195 | ]; 196 | 197 | /** 198 | * Get a random operating system 199 | * 200 | * @param string|null $os 201 | * @return string 202 | */ 203 | public function getOS(string|null $os = NULL): string 204 | { 205 | $_os = []; 206 | if ($os === NULL || in_array($os, ['chrome', 'firefox', 'explorer'])) { 207 | $_os = $os === 'explorer' ? $this->windows_os : array_merge($this->windows_os, $this->linux_os, $this->mac_os); 208 | } else { 209 | $_os += $this->{$os . '_os'}; 210 | } 211 | // randomly select on operating system 212 | $selected_os = rtrim($_os[rand(0, count($_os) - 1)], ';'); 213 | 214 | // check for spin syntax 215 | if (str_contains($selected_os, '[')) { 216 | $selected_os = self::processSpinSyntax($selected_os); 217 | } 218 | 219 | // check for random number syntax 220 | if (str_contains($selected_os, ':number')) { 221 | $selected_os = self::processRandomNumbers($selected_os); 222 | } 223 | 224 | if (rand(1, 100) > 50) { 225 | $selected_os .= '; en-US'; 226 | } 227 | return $selected_os; 228 | } 229 | 230 | /** 231 | * Get Mobile OS 232 | * 233 | * @param null|string $os Can specify android, iphone, ipad, ipod, or null/blank for random 234 | * @return string * 235 | */ 236 | public function getMobileOS(string|null $os = NULL): string 237 | { 238 | $os = strtolower($os); 239 | $_os = []; 240 | switch ($os) { 241 | case'android': 242 | $_os += $this->android_os; 243 | break; 244 | case 'iphone': 245 | case 'ipad': 246 | case 'ipod': 247 | $_os[] = $this->mobile_ios[$os]; 248 | break; 249 | default: 250 | $_os = array_merge($this->android_os, array_values($this->mobile_ios)); 251 | } 252 | // select random mobile os 253 | $selected_os = rtrim($_os[rand(0, count($_os) - 1)], ';'); 254 | if (str_contains($selected_os, ':androidVersion:')) { 255 | $selected_os = $this->processAndroidVersion($selected_os); 256 | } 257 | if (str_contains($selected_os, ':androidDevice:')) { 258 | $selected_os = $this->addAndroidDevice($selected_os); 259 | } 260 | if (str_contains($selected_os, ':number')) { 261 | $selected_os = self::processRandomNumbers($selected_os); 262 | } 263 | return $selected_os; 264 | } 265 | 266 | /** 267 | * @param $selected_os 268 | * @return null|string|string[] 269 | */ 270 | public static function processRandomNumbers($selected_os): null|string|array 271 | { 272 | return preg_replace_callback('/:number(\d+)-(\d+):/i', function ($matches) { 273 | return rand((int)$matches[1], (int)$matches[2]); 274 | }, $selected_os); 275 | } 276 | 277 | /** 278 | * @param $selected_os 279 | * @return null|string|string[] 280 | */ 281 | public static function processSpinSyntax($selected_os): null|string|array 282 | { 283 | return preg_replace_callback('/\[([\w\-\s|;]*?)]/i', function ($matches) { 284 | $shuffle = explode('|', $matches[1]); 285 | return $shuffle[array_rand($shuffle)]; 286 | }, $selected_os); 287 | } 288 | 289 | /** 290 | * @param $selected_os 291 | * @return null|string|string[] 292 | */ 293 | public function processAndroidVersion($selected_os): null|string|array 294 | { 295 | $this->androidVersion = $version = $this->androidVersions[array_rand($this->androidVersions)]; 296 | return preg_replace_callback('/:androidVersion:/i', function ($matches) use ($version) { 297 | return $version; 298 | }, $selected_os); 299 | } 300 | 301 | /** 302 | * @param $selected_os 303 | * @return null|string|string[] 304 | */ 305 | public function addAndroidDevice($selected_os): null|string|array 306 | { 307 | $devices = $this->androidDevices[substr($this->androidVersion, 0, 3)]; 308 | $device = $devices[array_rand($devices)]; 309 | 310 | $device = self::processSpinSyntax($device); 311 | return preg_replace_callback('/:androidDevice:/i', function ($matches) use ($device) { 312 | return $device; 313 | }, $selected_os); 314 | } 315 | 316 | /** 317 | * @param $version 318 | * @return string 319 | */ 320 | public static function chromeVersion($version): string 321 | { 322 | return rand($version['min'], $version['max']) . '.0.' . rand(1000, 4000) . '.' . rand(100, 400); 323 | } 324 | 325 | /** 326 | * @param $version 327 | * @return string 328 | */ 329 | protected function firefoxVersion($version): string 330 | { 331 | return rand($version['min'], $version['max']) . '.' . rand(0, 9); 332 | } 333 | 334 | /** 335 | * @param $version 336 | * @return string 337 | */ 338 | protected function windows($version): string 339 | { 340 | return rand($version['min'], $version['max']) . '.' . rand(0, 9); 341 | } 342 | 343 | /** 344 | * generate 345 | * 346 | * @param string|null $userAgent [optional] {chrome, firefox, explorer, safari, opera, android, iphone, ipad, ipod} 347 | * @return string * 348 | */ 349 | public function generate(string|null $userAgent = NULL): string 350 | { 351 | if ($userAgent === NULL) { 352 | $r = rand(0, 100); 353 | if ($r >= 44) { 354 | $userAgent = array_rand(['firefox' => 1, 'chrome' => 1, 'explorer' => 1]); 355 | } else { 356 | $userAgent = array_rand(['iphone' => 1, 'android' => 1, 'mobile' => 1]); 357 | } 358 | } elseif ($userAgent == 'windows' || $userAgent == 'mac' || $userAgent == 'linux') { 359 | $agents = ['firefox' => 1, 'chrome' => 1]; 360 | if ($userAgent == 'windows') { 361 | $agents['explorer'] = 1; 362 | } 363 | $userAgent = array_rand($agents); 364 | } 365 | $_SESSION['agent'] = $userAgent; 366 | return match ($userAgent) { 367 | 'chrome' => 'Mozilla/5.0 (' . $this->getOS($userAgent) . ') AppleWebKit/' . (rand(1, 100) > 50 ? rand(533, 537) : rand(600, 603)) . '.' . rand(1, 50) . ' (KHTML, like Gecko) Chrome/' . self::chromeVersion(['min' => 47, 'max' => 55]) . ' Safari/' . (rand(1, 100) > 50 ? rand(533, 537) : rand(600, 603)), 368 | 'firefox' => 'Mozilla/5.0 (' . $this->getOS($userAgent) . ') Gecko/' . (rand(1, 100) > 30 ? '20100101' : '20130401') . ' Firefox/' . self::firefoxVersion(['min' => 45, 'max' => 74]), 369 | 'explorer' => 'Mozilla / 5.0 (compatible; MSIE ' . ($int = rand(7, 11)) . '.0; ' . $this->getOS('windows') . ' Trident / ' . ($int == 7 || $int == 8 ? '4' : ($int == 9 ? '5' : ($int == 10 ? '6' : '7'))) . '.0)', 370 | 'mobile', 'android', 'iphone', 'ipad', 'ipod' => 'Mozilla/5.0 (' . $this->getMobileOS($userAgent) . ') AppleWebKit/' . (rand(1, 100) > 50 ? rand(533, 537) : rand(600, 603)) . '.' . rand(1, 50) . ' (KHTML, like Gecko) Chrome/' . self::chromeVersion(['min' => 47, 'max' => 55]) . ' Mobile Safari/' . (rand(1, 100) > 50 ? rand(533, 537) : rand(600, 603)) . '.' . rand(0, 9), 371 | default => throw new \RuntimeException('Unable to determine user agent to generate') 372 | }; 373 | } 374 | 375 | } --------------------------------------------------------------------------------