├── .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 |
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 | [](https://scrutinizer-ci.com/g/shahradelahi/easy-http/build-status/master)
7 | [](https://scrutinizer-ci.com/g/shahradelahi/easy-http/?branch=master)
8 | [](https://scrutinizer-ci.com/g/shahradelahi/easy-http/?b=master)
9 | [](https://packagist.org/packages/shahradelahi/easy-http)
10 | [](https://php.net/)
11 | [](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 | }
--------------------------------------------------------------------------------