CurlHandle   B
last analyzed

Complexity

Total Complexity 49

Size/Duplication

Total Lines 384
Duplicated Lines 0 %

Importance

Changes 7
Bugs 0 Features 0
Metric Value
eloc 161
dl 0
loc 384
rs 8.48
c 7
b 0
f 0
wmc 49

18 Methods

Rating   Name   Duplication   Size   Complexity  
A close() 0 8 2
A __destruct() 0 2 1
A getCurlOptions() 0 2 1
A initCurlOptions() 0 20 1
A setSSLOptions() 0 14 5
A __construct() 0 13 1
A getResponse() 0 4 1
A headerfunction() 0 19 4
A getError() 0 2 1
B setHeaderOptions() 0 43 9
A writefunction() 0 2 1
A init() 0 16 3
A exec() 0 9 2
A getCurlResource() 0 2 1
B setRequestOptions() 0 43 8
A getRequest() 0 2 1
A readfunction() 0 2 1
A setBodyOptions() 0 25 6

How to fix   Complexity   

Complex Class

Complex classes like CurlHandle often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use CurlHandle, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * Class CurlHandle
4
 *
5
 * @created      30.08.2018
6
 * @author       smiley <[email protected]>
7
 * @copyright    2018 smiley
8
 * @license      MIT
9
 */
10
11
namespace chillerlan\HTTP\CurlUtils;
12
13
use chillerlan\HTTP\HTTPOptions;
14
use chillerlan\HTTP\Psr18\ClientException;
15
use chillerlan\Settings\SettingsContainerInterface;
16
use Psr\Http\Message\{RequestInterface, ResponseInterface, StreamInterface};
17
use CurlHandle as CH;
18
19
use function array_key_exists, count, curl_close, curl_errno, curl_error, curl_exec, curl_init, curl_setopt_array,
20
	explode, in_array, strlen, strtolower, strtoupper, substr, trim;
21
22
use const CURL_HTTP_VERSION_2TLS, CURLE_COULDNT_CONNECT, CURLE_COULDNT_RESOLVE_HOST, CURLE_COULDNT_RESOLVE_PROXY,
23
	CURLE_GOT_NOTHING, CURLE_OPERATION_TIMEOUTED, CURLE_SSL_CONNECT_ERROR, CURLOPT_CAINFO, CURLOPT_CAPATH,
24
	CURLOPT_CONNECTTIMEOUT, CURLOPT_CUSTOMREQUEST, CURLOPT_FOLLOWLOCATION, CURLOPT_HEADER, CURLOPT_HEADERFUNCTION,
25
	CURLOPT_HTTP_VERSION, CURLOPT_HTTPHEADER, CURLOPT_INFILESIZE, CURLOPT_MAXREDIRS, CURLOPT_NOBODY, CURLOPT_POSTFIELDS,
26
	CURLOPT_PROTOCOLS, CURLOPT_READFUNCTION, CURLOPT_REDIR_PROTOCOLS, CURLOPT_RETURNTRANSFER, CURLOPT_SSL_VERIFYHOST,
27
	CURLOPT_SSL_VERIFYPEER, CURLOPT_SSL_VERIFYSTATUS, CURLOPT_TIMEOUT, CURLOPT_UPLOAD, CURLOPT_URL, CURLOPT_USERAGENT,
28
	CURLOPT_USERPWD, CURLOPT_WRITEFUNCTION, CURLPROTO_HTTP, CURLPROTO_HTTPS;
29
30
class CurlHandle{
31
32
	public const CURL_NETWORK_ERRORS = [
33
		CURLE_COULDNT_RESOLVE_PROXY,
34
		CURLE_COULDNT_RESOLVE_HOST,
35
		CURLE_COULDNT_CONNECT,
36
		CURLE_OPERATION_TIMEOUTED,
37
		CURLE_SSL_CONNECT_ERROR,
38
		CURLE_GOT_NOTHING,
39
	];
40
41
	// https://www.php.net/manual/function.curl-getinfo.php#111678
42
	// https://www.openssl.org/docs/manmaster/man1/verify.html#VERIFY_OPERATION
43
	// https://github.com/openssl/openssl/blob/91cb81d40a8102c3d8667629661be8d6937db82b/include/openssl/x509_vfy.h#L99-L189
44
	public const CURLINFO_SSL_VERIFYRESULT = [
45
		0  => 'ok the operation was successful.',
46
		2  => 'unable to get issuer certificate',
47
		3  => 'unable to get certificate CRL',
48
		4  => 'unable to decrypt certificate\'s signature',
49
		5  => 'unable to decrypt CRL\'s signature',
50
		6  => 'unable to decode issuer public key',
51
		7  => 'certificate signature failure',
52
		8  => 'CRL signature failure',
53
		9  => 'certificate is not yet valid',
54
		10 => 'certificate has expired',
55
		11 => 'CRL is not yet valid',
56
		12 => 'CRL has expired',
57
		13 => 'format error in certificate\'s notBefore field',
58
		14 => 'format error in certificate\'s notAfter field',
59
		15 => 'format error in CRL\'s lastUpdate field',
60
		16 => 'format error in CRL\'s nextUpdate field',
61
		17 => 'out of memory',
62
		18 => 'self signed certificate',
63
		19 => 'self signed certificate in certificate chain',
64
		20 => 'unable to get local issuer certificate',
65
		21 => 'unable to verify the first certificate',
66
		22 => 'certificate chain too long',
67
		23 => 'certificate revoked',
68
		24 => 'invalid CA certificate',
69
		25 => 'path length constraint exceeded',
70
		26 => 'unsupported certificate purpose',
71
		27 => 'certificate not trusted',
72
		28 => 'certificate rejected',
73
		29 => 'subject issuer mismatch',
74
		30 => 'authority and subject key identifier mismatch',
75
		31 => 'authority and issuer serial number mismatch',
76
		32 => 'key usage does not include certificate signing',
77
		50 => 'application verification failure',
78
	];
79
80
	protected ?CH                                    $curl        = null;
81
	protected array                                  $curlOptions = [];
82
	protected bool                                   $initialized = false;
83
	protected HTTPOptions|SettingsContainerInterface $options;
84
	protected RequestInterface                       $request;
85
	protected ResponseInterface                      $response;
86
	protected StreamInterface                        $requestBody;
87
	protected StreamInterface                        $responseBody;
88
89
	/**
90
	 * CurlHandle constructor.
91
	 */
92
	public function __construct(
93
		RequestInterface $request,
94
		ResponseInterface $response,
95
		HTTPOptions|SettingsContainerInterface $options,
96
		StreamInterface $stream = null
97
	){
98
		$this->request  = $request;
99
		$this->response = $response;
100
		$this->options  = $options;
101
		$this->curl     = curl_init();
102
103
		$this->requestBody  = $this->request->getBody();
104
		$this->responseBody = $stream ?? $this->response->getBody();
105
	}
106
107
	/**
108
	 * close an existing cURL handle on exit
109
	 */
110
	public function __destruct(){
111
		$this->close();
112
	}
113
114
	/**
115
	 *
116
	 */
117
	public function close():CurlHandle{
118
119
		if($this->curl instanceof CH){
120
			/** @phan-suppress-next-line PhanTypeMismatchArgumentInternalReal */
121
			curl_close($this->curl);
122
		}
123
124
		return $this;
125
	}
126
127
	/**
128
	 * @codeCoverageIgnore
129
	 */
130
	public function getCurlResource():?CH{
131
		return $this->curl;
132
	}
133
134
	/**
135
	 * @codeCoverageIgnore
136
	 */
137
	public function getRequest():RequestInterface{
138
		return $this->request;
139
	}
140
141
	/**
142
	 * @codeCoverageIgnore
143
	 */
144
	public function getResponse():ResponseInterface{
145
		$this->responseBody->rewind();
146
147
		return $this->response->withBody($this->responseBody);
148
	}
149
150
	/**
151
	 * @codeCoverageIgnore
152
	 */
153
	public function getCurlOptions():array{
154
		return $this->curlOptions;
155
	}
156
157
	/**
158
	 * @link https://php.watch/articles/php-curl-security-hardening
159
	 */
160
	protected function initCurlOptions():array{
161
		$this->curlOptions = [
162
			CURLOPT_HEADER           => false,
163
			CURLOPT_RETURNTRANSFER   => true,
164
			CURLOPT_FOLLOWLOCATION   => false,
165
			CURLOPT_MAXREDIRS        => 5,
166
			CURLOPT_URL              => (string)$this->request->getUri()->withFragment(''),
167
			CURLOPT_HTTP_VERSION     => CURL_HTTP_VERSION_2TLS,
168
			CURLOPT_USERAGENT        => $this->options->user_agent,
0 ignored issues
show
Bug Best Practice introduced by
The property $user_agent is declared protected in chillerlan\HTTP\HTTPOptions. Since you implement __get, consider adding a @property or @property-read.
Loading history...
169
			CURLOPT_PROTOCOLS        => CURLPROTO_HTTP | CURLPROTO_HTTPS,
170
			CURLOPT_REDIR_PROTOCOLS  => CURLPROTO_HTTPS,
171
			CURLOPT_TIMEOUT          => $this->options->timeout,
0 ignored issues
show
Bug Best Practice introduced by
The property $timeout is declared protected in chillerlan\HTTP\HTTPOptions. Since you implement __get, consider adding a @property or @property-read.
Loading history...
172
			CURLOPT_CONNECTTIMEOUT   => 30,
173
		];
174
175
		$this->setSSLOptions();
176
		$this->setRequestOptions();
177
		$this->setHeaderOptions();
178
179
		return $this->curlOptions;
180
	}
181
182
	/**
183
	 *
184
	 */
185
	protected function setBodyOptions():void{
186
		$bodySize = $this->requestBody->getSize();
187
188
		if($bodySize === 0){
189
			return;
190
		}
191
192
		if($this->requestBody->isSeekable()){
193
			$this->requestBody->rewind();
194
		}
195
196
		// Message has non-empty body.
197
		if($bodySize === null || $bodySize > (1 << 20)){
198
			// Avoid full loading large or unknown size body into memory
199
			$this->curlOptions[CURLOPT_UPLOAD] = true;
200
201
			if($bodySize !== null){
202
				$this->curlOptions[CURLOPT_INFILESIZE] = $bodySize;
203
			}
204
205
			$this->curlOptions[CURLOPT_READFUNCTION] = [$this, 'readfunction'];
206
		}
207
		// Small body can be loaded into memory
208
		else{
209
			$this->curlOptions[CURLOPT_POSTFIELDS] = (string)$this->requestBody;
210
		}
211
	}
212
213
	/**
214
	 *
215
	 */
216
	protected function setSSLOptions():void{
217
		$this->curlOptions[CURLOPT_SSL_VERIFYHOST] = 2;
218
		$this->curlOptions[CURLOPT_SSL_VERIFYPEER] = false;
219
220
		if($this->options->ca_info !== null){
0 ignored issues
show
Bug Best Practice introduced by
The property $ca_info is declared protected in chillerlan\HTTP\HTTPOptions. Since you implement __get, consider adding a @property or @property-read.
Loading history...
221
			$opt                     = $this->options->ca_info_is_path ? CURLOPT_CAPATH : CURLOPT_CAINFO;
0 ignored issues
show
Bug Best Practice introduced by
The property $ca_info_is_path is declared protected in chillerlan\HTTP\HTTPOptions. Since you implement __get, consider adding a @property or @property-read.
Loading history...
222
			$this->curlOptions[$opt] = $this->options->ca_info;
223
224
			if($this->options->ssl_verifypeer){
0 ignored issues
show
Bug Best Practice introduced by
The property $ssl_verifypeer is declared protected in chillerlan\HTTP\HTTPOptions. Since you implement __get, consider adding a @property or @property-read.
Loading history...
225
				$this->curlOptions[CURLOPT_SSL_VERIFYPEER] = true;
226
			}
227
228
			if($this->options->curl_check_OCSP){
0 ignored issues
show
Bug Best Practice introduced by
The property $curl_check_OCSP is declared protected in chillerlan\HTTP\HTTPOptions. Since you implement __get, consider adding a @property or @property-read.
Loading history...
229
				$this->curlOptions[CURLOPT_SSL_VERIFYSTATUS] = true;
230
			}
231
		}
232
233
	}
234
235
	/**
236
	 *
237
	 */
238
	protected function setHeaderOptions():void{
239
		$headers = [];
240
241
		foreach($this->request->getHeaders() as $name => $values){
242
			$header = strtolower($name);
243
244
			// curl-client does not support "Expect-Continue", so dropping "expect" headers
245
			if($header === 'expect'){
246
				continue;
247
			}
248
249
			if($header === 'content-length'){
250
251
				// Small body content length can be calculated here.
252
				if(array_key_exists(CURLOPT_POSTFIELDS, $this->curlOptions)){
253
					$values = [strlen($this->curlOptions[CURLOPT_POSTFIELDS])];
254
				}
255
				// Else if there is no body, forcing "Content-length" to 0
256
				elseif(!array_key_exists(CURLOPT_READFUNCTION, $this->curlOptions)){
257
					$values = ['0'];
258
				}
259
260
			}
261
262
			foreach($values as $value){
263
				$value = (string)$value;
264
265
				// cURL requires a special format for empty headers.
266
				// See https://github.com/guzzle/guzzle/issues/1882 for more details.
267
				$headers[] = $value === '' ? $name.';' : $name.': '.$value;
268
			}
269
270
		}
271
272
		// If the Expect header is not present (it isn't), prevent curl from adding it
273
		$headers[] = 'Expect:';
274
275
		// cURL sometimes adds a content-type by default. Prevent this.
276
		if(!$this->request->hasHeader('Content-Type')){
277
			$headers[] = 'Content-Type:';
278
		}
279
280
		$this->curlOptions[CURLOPT_HTTPHEADER] = $headers;
281
	}
282
283
	/**
284
	 * @throws \chillerlan\HTTP\Psr18\ClientException
285
	 */
286
	public function setRequestOptions():void{
287
		$method   = $this->request->getMethod();
288
		$userinfo = $this->request->getUri()->getUserInfo();
289
290
		if($method === ''){
291
			throw new ClientException('invalid HTTP method');
292
		}
293
294
		if(!empty($userinfo)){
295
			$this->curlOptions[CURLOPT_USERPWD] = $userinfo;
296
		}
297
298
		/*
299
		 * Some HTTP methods cannot have payload:
300
		 *
301
		 * - GET   — cURL will automatically change the method to PUT or POST
302
		 *           if we set CURLOPT_UPLOAD or CURLOPT_POSTFIELDS.
303
		 * - HEAD  — cURL treats HEAD as a GET request with same restrictions.
304
		 * - TRACE — According to RFC7231: a client MUST NOT send a message body in a TRACE request.
305
		 */
306
307
		if(in_array($method, ['DELETE', 'PATCH', 'POST', 'PUT'], true)){
308
			$this->setBodyOptions();
309
		}
310
311
		// This will set HTTP method to "HEAD".
312
		if($method === 'HEAD'){
313
			$this->curlOptions[CURLOPT_NOBODY] = true;
314
		}
315
316
		// GET is a default method. Other methods should be specified explicitly.
317
		if($method !== 'GET'){
318
			$this->curlOptions[CURLOPT_CUSTOMREQUEST] = $method;
319
		}
320
321
		// overwrite the default values with $curl_options
322
		foreach($this->options->curl_options as $k => $v){
0 ignored issues
show
Bug Best Practice introduced by
The property $curl_options is declared protected in chillerlan\HTTP\HTTPOptions. Since you implement __get, consider adding a @property or @property-read.
Loading history...
323
			// skip some options that are only set automatically
324
			if(in_array($k, [CURLOPT_HTTPHEADER, CURLOPT_CUSTOMREQUEST, CURLOPT_NOBODY], true)){
325
				continue;
326
			}
327
328
			$this->curlOptions[$k] = $v;
329
		}
330
331
	}
332
333
	/**
334
	 *
335
	 */
336
	public function init():?CH{
337
		$options = $this->initCurlOptions();
338
339
		if(!isset($options[CURLOPT_HEADERFUNCTION])){
340
			$options[CURLOPT_HEADERFUNCTION] = [$this, 'headerfunction'];
341
		}
342
343
		if(!isset($options[CURLOPT_WRITEFUNCTION])){
344
			$options[CURLOPT_WRITEFUNCTION] = [$this, 'writefunction'];
345
		}
346
347
		curl_setopt_array($this->curl, $options);
348
349
		$this->initialized = true;
350
351
		return $this->curl;
352
	}
353
354
	/**
355
	 *
356
	 */
357
	public function exec():int{
358
359
		if(!$this->initialized){
360
			$this->init();
361
		}
362
363
		curl_exec($this->curl);
364
365
		return curl_errno($this->curl);
366
	}
367
368
	/**
369
	 *
370
	 */
371
	public function getError():string{
372
		return curl_error($this->curl);
373
	}
374
375
	/**
376
	 * @internal
377
	 * @noinspection PhpUnusedParameterInspection
378
	 */
379
	public function readfunction(CH $curl, $stream, int $length):string{
380
		return $this->requestBody->read($length);
381
	}
382
383
	/**
384
	 * @internal
385
	 * @noinspection PhpUnusedParameterInspection
386
	 */
387
	public function writefunction(CH $curl, string $data):int{
388
		return $this->responseBody->write($data);
389
	}
390
391
	/**
392
	 * @internal
393
	 * @noinspection PhpUnusedParameterInspection
394
	 */
395
	public function headerfunction(CH $curl, string $line):int{
396
		$str    = trim($line);
397
		$header = explode(':', $str, 2);
398
399
		if(count($header) === 2){
400
			$this->response = $this->response
401
				->withAddedHeader(trim($header[0]), trim($header[1]));
402
		}
403
		elseif(str_starts_with(strtoupper($str), 'HTTP/')){
404
			$status = explode(' ', $str, 3);
405
			$reason = count($status) > 2 ? trim($status[2]) : '';
406
407
			$this->response = $this->response
408
				->withStatus((int)$status[1], $reason)
409
				->withProtocolVersion(substr($status[0], 5))
410
			;
411
		}
412
413
		return strlen($line);
414
	}
415
416
}
417