Passed
Push — master ( da3f2a...c03036 )
by Shahrad
01:59
created

WebSocket::connect()   C

Complexity

Conditions 10
Paths 184

Size

Total Lines 81
Code Lines 56

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 10
eloc 56
c 0
b 0
f 0
nc 184
nop 2
dl 0
loc 81
rs 6.5333

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace EasyHttp;
4
5
use EasyHttp\Contracts\CommonsContract;
6
use EasyHttp\Contracts\WscCommonsContract;
7
use EasyHttp\Exceptions\BadOpcodeException;
8
use EasyHttp\Exceptions\ConnectionException;
9
use EasyHttp\Exceptions\WebSocketException;
10
use EasyHttp\Traits\WSClientTrait;
11
12
/**
13
 * WebSocket class
14
 *
15
 * @link    https://github.com/shahradelahi/easy-http
16
 * @author  Shahrad Elahi (https://github.com/shahradelahi)
17
 * @license https://github.com/shahradelahi/easy-http/blob/master/LICENSE (MIT License)
18
 */
19
class WebSocket implements WscCommonsContract
20
{
21
22
	use WSClientTrait;
23
24
	/**
25
	 * App version
26
	 *
27
	 * @var string
28
	 */
29
	public const VERSION = 'v1.0.0';
30
31
	/**
32
	 * @var resource|bool
33
	 */
34
	private $socket;
35
36
	/**
37
	 * @var bool
38
	 */
39
	private bool $isConnected = false;
40
41
	/**
42
	 * @var bool
43
	 */
44
	private bool $isClosing = false;
45
46
	/**
47
	 * @var string
48
	 */
49
	private string $lastOpcode;
50
51
	/**
52
	 * @var float|int
53
	 */
54
	private float|int $closeStatus;
55
56
	/**
57
	 * @var string|null
58
	 */
59
	private ?string $hugePayload;
60
61
	/**
62
	 * @var array|int[]
63
	 */
64
	private static array $opcodes = [
65
		CommonsContract::EVENT_TYPE_CONTINUATION => 0,
66
		CommonsContract::EVENT_TYPE_TEXT => 1,
67
		CommonsContract::EVENT_TYPE_BINARY => 2,
68
		CommonsContract::EVENT_TYPE_CLOSE => 8,
69
		CommonsContract::EVENT_TYPE_PING => 9,
70
		CommonsContract::EVENT_TYPE_PONG => 10,
71
	];
72
73
	/**
74
	 * @var WebSocketConfig
75
	 */
76
	protected WebSocketConfig $config;
77
78
	/**
79
	 * @var string
80
	 */
81
	protected string $socketUrl;
82
83
	/**
84
	 * @var ?SocketClient
85
	 */
86
	protected ?SocketClient $client = null;
87
88
	/**
89
	 * Sets parameters for Web Socket Client intercommunication
90
	 *
91
	 * @param SocketClient|string $clientOrUri pass the SocketClient object or the URI of the server
92
	 * @param ?WebSocketConfig $config if you're passing the URI, you can pass the config object
93
	 */
94
	public function __construct(SocketClient|string $clientOrUri, ?WebSocketConfig $config = null)
95
	{
96
		if ($clientOrUri instanceof SocketClient) {
97
			$this->client = $clientOrUri;
98
		} else {
99
			$this->connect($clientOrUri, $config === null ? new WebSocketConfig() : $config);
100
		}
101
	}
102
103
	/**
104
	 * @param string $socketUrl string that represents the URL of the Web Socket server. e.g. ws://localhost:1337 or wss://localhost:1337
105
	 * @param WebSocketConfig $config The configuration for the Web Socket client
106
	 */
107
	public function connect(string $socketUrl, WebSocketConfig $config): void
108
	{
109
		try {
110
			$this->config = $config;
111
			$this->socketUrl = $socketUrl;
112
			$urlParts = parse_url($this->socketUrl);
113
114
			$this->config->setScheme($urlParts['scheme']);
115
			$this->config->setHost($urlParts['host']);
116
			$this->config->setUser($urlParts);
117
			$this->config->setPassword($urlParts);
118
			$this->config->setPort($urlParts);
119
120
			$pathWithQuery = $this->getPathWithQuery($urlParts);
121
			$hostUri = $this->getHostUri($this->config);
122
123
			$context = $this->getStreamContext();
124
			if ($this->config->hasProxy()) {
125
				$this->socket = $this->proxy();
126
			} else {
127
				$this->socket = @stream_socket_client(
128
					$hostUri . ':' . $this->config->getPort(),
129
					$errno,
130
					$errstr,
131
					$this->config->getTimeout(),
132
					STREAM_CLIENT_CONNECT,
133
					$context
134
				);
135
			}
136
137
			if ($this->socket === false) {
138
				throw new ConnectionException(
139
					"Could not open socket to \"{$this->config->getHost()}:{$this->config->getPort()}\": $errstr ($errno).",
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $errno does not seem to be defined for all execution paths leading up to this point.
Loading history...
Comprehensibility Best Practice introduced by
The variable $errstr does not seem to be defined for all execution paths leading up to this point.
Loading history...
140
					CommonsContract::CLIENT_COULD_NOT_OPEN_SOCKET
141
				);
142
			}
143
144
			stream_set_timeout($this->socket, $this->config->getTimeout());
145
146
			$key = $this->generateKey();
147
			$headers = [
148
				'Host' => $this->config->getHost() . ':' . $this->config->getPort(),
149
				'User-Agent' => 'Easy-Http/' . self::VERSION . ' (PHP/' . PHP_VERSION . ')',
150
				'Connection' => 'Upgrade',
151
				'Upgrade' => 'WebSocket',
152
				'Sec-WebSocket-Key' => $key,
153
				'Sec-Websocket-Version' => '13',
154
			];
155
156
			if ($this->config->getUser() || $this->config->getPassword()) {
157
				$headers['authorization'] = 'Basic ' . base64_encode($this->config->getUser() . ':' . $this->config->getPassword()) . "\r\n";
158
			}
159
160
			if (!empty($this->config->getHeaders())) {
161
				$headers = array_merge($headers, $this->config->getHeaders());
162
			}
163
164
			$header = $this->getHeaders($pathWithQuery, $headers);
165
166
			$this->write($header);
167
168
			$this->validateResponse($this->config, $pathWithQuery, $key);
169
			$this->isConnected = true;
170
171
			if ($this->client !== null) {
172
				$this->client->setConnection($this);
173
				$this->client->onOpen();
174
				while ($this->isConnected()) {
175
					if (is_string(($message = $this->receive()))) {
176
						$this->client->onMessage($message);
177
					}
178
				}
179
				$this->client->onClose($this->closeStatus);
0 ignored issues
show
Bug introduced by
It seems like $this->closeStatus can also be of type double; however, parameter $closeStatus of EasyHttp\Contracts\WebSocketContract::onClose() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

179
				$this->client->onClose(/** @scrutinizer ignore-type */ $this->closeStatus);
Loading history...
180
			}
181
182
		} catch (\Exception $e) {
183
			$this->client->onError(
0 ignored issues
show
Bug introduced by
The method onError() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

183
			$this->client->/** @scrutinizer ignore-call */ 
184
                  onError(

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
184
				new WebSocketException(
185
					$e->getMessage(),
186
					$e->getCode(),
187
					$e
188
				)
189
			);
190
		}
191
	}
192
193
	/**
194
	 * Init a proxy connection
195
	 *
196
	 * @return resource|false
197
	 * @throws \InvalidArgumentException
198
	 * @throws ConnectionException
199
	 */
200
	private function proxy()
201
	{
202
		$sock = @stream_socket_client(
203
			WscCommonsContract::TCP_SCHEME . $this->config->getProxyIp() . ':' . $this->config->getProxyPort(),
204
			$errno,
205
			$errstr,
206
			$this->config->getTimeout(),
207
			STREAM_CLIENT_CONNECT,
208
			$this->getStreamContext()
209
		);
210
		$write = "CONNECT {$this->config->getProxyIp()}:{$this->config->getProxyPort()} HTTP/1.1\r\n";
211
		$auth = $this->config->getProxyAuth();
212
		if ($auth !== NULL) {
213
			$write .= "Proxy-Authorization: Basic {$auth}\r\n";
214
		}
215
		$write .= "\r\n";
216
		fwrite($sock, $write);
0 ignored issues
show
Bug introduced by
It seems like $sock can also be of type false; however, parameter $stream of fwrite() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

216
		fwrite(/** @scrutinizer ignore-type */ $sock, $write);
Loading history...
217
		$resp = fread($sock, 1024);
0 ignored issues
show
Bug introduced by
It seems like $sock can also be of type false; however, parameter $stream of fread() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

217
		$resp = fread(/** @scrutinizer ignore-type */ $sock, 1024);
Loading history...
218
219
		if (preg_match(self::PROXY_MATCH_RESP, $resp) === 1) {
220
			return $sock;
221
		}
222
223
		throw new ConnectionException('Failed to connect to the host via proxy');
224
	}
225
226
	/**
227
	 * @return mixed
228
	 * @throws \InvalidArgumentException
229
	 */
230
	private function getStreamContext(): mixed
231
	{
232
		if ($this->config->getContext() !== null) {
233
			// Suppress the error since we'll catch it below
234
			if (@get_resource_type($this->config->getContext()) === 'stream-context') {
235
				return $this->config->getContext();
236
			}
237
238
			throw new \InvalidArgumentException(
239
				'Stream context is invalid',
240
				CommonsContract::CLIENT_INVALID_STREAM_CONTEXT
241
			);
242
		}
243
244
		return stream_context_create($this->config->getContextOptions());
245
	}
246
247
	/**
248
	 * @param mixed $urlParts
249
	 * @return string
250
	 */
251
	private function getPathWithQuery(mixed $urlParts): string
252
	{
253
		$path = isset($urlParts['path']) ? $urlParts['path'] : '/';
254
		$query = isset($urlParts['query']) ? $urlParts['query'] : '';
255
		$fragment = isset($urlParts['fragment']) ? $urlParts['fragment'] : '';
256
		$pathWithQuery = $path;
257
		if (!empty($query)) {
258
			$pathWithQuery .= '?' . $query;
259
		}
260
		if (!empty($fragment)) {
261
			$pathWithQuery .= '#' . $fragment;
262
		}
263
264
		return $pathWithQuery;
265
	}
266
267
	/**
268
	 * @param string $pathWithQuery
269
	 * @param array $headers
270
	 * @return string
271
	 */
272
	private function getHeaders(string $pathWithQuery, array $headers): string
273
	{
274
		return 'GET ' . $pathWithQuery . " HTTP/1.1\r\n"
275
			. implode(
276
				"\r\n",
277
				array_map(
278
					function ($key, $value) {
279
						return "$key: $value";
280
					},
281
					array_keys($headers),
282
					$headers
283
				)
284
			)
285
			. "\r\n\r\n";
286
	}
287
288
	/**
289
	 * @return string
290
	 */
291
	public function getLastOpcode(): string
292
	{
293
		return $this->lastOpcode;
294
	}
295
296
	/**
297
	 * @return int
298
	 */
299
	public function getCloseStatus(): int
300
	{
301
		return $this->closeStatus;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->closeStatus could return the type double which is incompatible with the type-hinted return integer. Consider adding an additional type-check to rule them out.
Loading history...
302
	}
303
304
	/**
305
	 * @return bool
306
	 */
307
	public function isConnected(): bool
308
	{
309
		return $this->isConnected;
310
	}
311
312
	/**
313
	 * @param int $timeout
314
	 * @param null $microSecs
0 ignored issues
show
Documentation Bug introduced by
Are you sure the doc-type for parameter $microSecs is correct as it would always require null to be passed?
Loading history...
315
	 * @return WebSocket
316
	 */
317
	public function setTimeout(int $timeout, $microSecs = null): WebSocket
318
	{
319
		$this->config->setTimeout($timeout);
320
		if ($this->socket && get_resource_type($this->socket) === 'stream') {
0 ignored issues
show
Bug introduced by
It seems like $this->socket can also be of type true; however, parameter $resource of get_resource_type() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

320
		if ($this->socket && get_resource_type(/** @scrutinizer ignore-type */ $this->socket) === 'stream') {
Loading history...
321
			stream_set_timeout($this->socket, $timeout, $microSecs);
0 ignored issues
show
Bug introduced by
It seems like $this->socket can also be of type true; however, parameter $stream of stream_set_timeout() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

321
			stream_set_timeout(/** @scrutinizer ignore-type */ $this->socket, $timeout, $microSecs);
Loading history...
Bug introduced by
$microSecs of type null is incompatible with the type integer expected by parameter $microseconds of stream_set_timeout(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

321
			stream_set_timeout($this->socket, $timeout, /** @scrutinizer ignore-type */ $microSecs);
Loading history...
322
		}
323
324
		return $this;
325
	}
326
327
	/**
328
	 * Sends message to opened socket connection client->server
329
	 *
330
	 * @param $payload
331
	 * @param string $opcode
332
	 * @throws \Exception
333
	 */
334
	public function send($payload, string $opcode = CommonsContract::EVENT_TYPE_TEXT): void
335
	{
336
		if (!$this->isConnected) {
337
			$this->connect($this->socketUrl, new WebSocketConfig());
338
		}
339
340
		if (array_key_exists($opcode, self::$opcodes) === false) {
341
			throw new BadOpcodeException(
342
				"Bad opcode '$opcode'.  Try 'text' or 'binary'.",
343
				CommonsContract::CLIENT_BAD_OPCODE
344
			);
345
		}
346
347
		$payloadLength = strlen($payload);
348
		$fragmentCursor = 0;
349
350
		while ($payloadLength > $fragmentCursor) {
351
			$subPayload = substr($payload, $fragmentCursor, $this->config->getFragmentSize());
352
			$fragmentCursor += $this->config->getFragmentSize();
353
			$final = $payloadLength <= $fragmentCursor;
354
			$this->sendFragment($final, $subPayload, $opcode, true);
355
			$opcode = 'continuation';
356
		}
357
	}
358
359
	/**
360
	 * Receives message client<-server
361
	 *
362
	 * @return string|null
363
	 * @throws \Exception
364
	 */
365
	public function receive(): string|null
366
	{
367
		if (!$this->isConnected) {
368
			$this->connect($this->socketUrl, new WebSocketConfig());
369
		}
370
371
		$this->hugePayload = '';
372
373
		return $this->receiveFragment();
374
	}
375
376
	/**
377
	 * Tell the socket to close.
378
	 *
379
	 * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4
380
	 * @param string $message A closing message, max 125 bytes.
381
	 * @return bool|null|string
382
	 * @throws \Exception
383
	 */
384
	public function close(int $status = 1000, string $message = 'ttfn'): bool|null|string
385
	{
386
		$statusBin = sprintf('%016b', $status);
387
		$statusStr = '';
388
389
		foreach (str_split($statusBin, 8) as $binstr) {
390
			$statusStr .= chr(bindec($binstr));
0 ignored issues
show
Bug introduced by
It seems like bindec($binstr) can also be of type double; however, parameter $codepoint of chr() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

390
			$statusStr .= chr(/** @scrutinizer ignore-type */ bindec($binstr));
Loading history...
391
		}
392
393
		$this->send($statusStr . $message, CommonsContract::EVENT_TYPE_CLOSE);
394
		$this->isClosing = true;
395
396
		return $this->receive(); // Receiving a close frame will close the socket now.
397
	}
398
399
	/**
400
	 * @param string $data
401
	 * @throws ConnectionException
402
	 */
403
	protected function write(string $data): void
404
	{
405
		Middleware::stream_write($this->socket, $data);
406
	}
407
408
	/**
409
	 * @param int $len
410
	 * @return string|null
411
	 * @throws ConnectionException
412
	 */
413
	protected function read(int $len): string|null
414
	{
415
		return Middleware::stream_read($this->socket, $len) ?: false;
0 ignored issues
show
Bug Best Practice introduced by
The expression return EasyHttp\Middlewa...>socket, $len) ?: false could return the type false which is incompatible with the type-hinted return null|string. Consider adding an additional type-check to rule them out.
Loading history...
416
	}
417
418
	/**
419
	 * Helper to convert a binary to a string of '0' and '1'.
420
	 *
421
	 * @param string $string
422
	 * @return string
423
	 */
424
	protected static function sprintB(string $string): string
425
	{
426
		$return = '';
427
		$strLen = strlen($string);
428
		for ($i = 0; $i < $strLen; $i++) {
429
			$return .= sprintf('%08b', ord($string[$i]));
430
		}
431
432
		return $return;
433
	}
434
435
}
436