Completed
Push — master ( 988ea9...868c80 )
by smiley
07:26
created

CurlHandle::init()   B

Complexity

Conditions 7
Paths 48

Size

Total Lines 48

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
nc 48
nop 0
dl 0
loc 48
rs 8.2012
c 0
b 0
f 0
1
<?php
2
/**
3
 * Class CurlHandle
4
 *
5
 * @filesource   CurlHandle.php
6
 * @created      30.08.2018
7
 * @package      chillerlan\HTTP\CurlUtils
8
 * @author       smiley <[email protected]>
9
 * @copyright    2018 smiley
10
 * @license      MIT
11
 */
12
13
namespace chillerlan\HTTP\CurlUtils;
14
15
use chillerlan\Settings\SettingsContainerInterface;
16
use Psr\Http\Message\{RequestInterface, ResponseInterface};
17
18
use function array_key_exists, count, curl_close, curl_init, curl_setopt_array,
19
	explode, in_array, is_resource, strlen, strtolower, strtoupper, substr, trim;
20
21
use const CURLOPT_CAINFO, CURLOPT_CONNECTTIMEOUT, CURLOPT_CUSTOMREQUEST, CURLOPT_FOLLOWLOCATION, CURLOPT_HEADER,
22
	CURLOPT_HEADERFUNCTION, CURLOPT_HTTP_VERSION, CURLOPT_HTTPHEADER, CURLOPT_INFILESIZE, CURLOPT_NOBODY,
23
	CURLOPT_POSTFIELDS, CURLOPT_PROTOCOLS, CURLOPT_READFUNCTION, CURLOPT_RETURNTRANSFER,
24
	CURLOPT_SSL_VERIFYHOST, CURLOPT_SSL_VERIFYPEER, CURLOPT_TIMEOUT, CURLOPT_UPLOAD, CURLOPT_URL, CURLOPT_USERAGENT,
25
	CURLOPT_USERPWD, CURLOPT_WRITEFUNCTION, CURLPROTO_HTTP, CURLPROTO_HTTPS, CURL_HTTP_VERSION_2TLS;
26
27
class CurlHandle implements CurlHandleInterface{
28
29
	/**
30
	 * The cURL handle
31
	 *
32
	 * @var resource
33
	 */
34
	public $curl;
35
36
	/**
37
	 * a handle ID (counter), used in CurlMultiClient
38
	 *
39
	 * @var int
40
	 */
41
	public int $id;
0 ignored issues
show
Bug introduced by
This code did not parse for me. Apparently, there is an error somewhere around this line:

Syntax error, unexpected T_STRING, expecting T_FUNCTION or T_CONST
Loading history...
42
43
	/**
44
	 * a retry counter, used in CurlMultiClient
45
	 *
46
	 * @var int
47
	 */
48
	public int $retries;
49
50
	/**
51
	 * @var \Psr\Http\Message\RequestInterface
52
	 */
53
	public RequestInterface $request;
54
55
	/**
56
	 * @var \Psr\Http\Message\ResponseInterface
57
	 */
58
	public ResponseInterface $response;
59
60
	/**
61
	 * @var \chillerlan\Settings\SettingsContainerInterface|\chillerlan\HTTP\HTTPOptions
62
	 */
63
	protected SettingsContainerInterface $options;
64
65
	/**
66
	 * CurlHandle constructor.
67
	 *
68
	 * @param \Psr\Http\Message\RequestInterface              $request
69
	 * @param \Psr\Http\Message\ResponseInterface             $response
70
	 * @param \chillerlan\Settings\SettingsContainerInterface $options
71
	 */
72
	public function __construct(RequestInterface $request, ResponseInterface $response, SettingsContainerInterface $options){
73
		$this->request  = $request;
74
		$this->response = $response;
75
		$this->options  = $options;
76
		$this->curl     = curl_init();
77
	}
78
79
	/**
80
	 * close an existing cURL handle on exit
81
	 */
82
	public function __destruct(){
83
		$this->close();
84
	}
85
86
	/**
87
	 * @return \chillerlan\HTTP\CurlUtils\CurlHandleInterface
88
	 */
89
	public function close():CurlHandleInterface{
90
91
		if(is_resource($this->curl)){
92
			curl_close($this->curl);
93
		}
94
95
		return $this;
96
	}
97
98
	/**
99
	 * @return array
100
	 */
101
	protected function initCurlOptions():array{
102
		return [
103
			CURLOPT_HEADER         => false,
104
			CURLOPT_RETURNTRANSFER => true,
105
			CURLOPT_FOLLOWLOCATION => false,
106
			CURLOPT_URL            => (string)$this->request->getUri()->withFragment(''),
107
			CURLOPT_HTTP_VERSION   => CURL_HTTP_VERSION_2TLS,
108
			CURLOPT_USERAGENT      => $this->options->user_agent,
109
			CURLOPT_PROTOCOLS      => CURLPROTO_HTTP | CURLPROTO_HTTPS,
110
			CURLOPT_SSL_VERIFYPEER => true,
111
			CURLOPT_SSL_VERIFYHOST => 2,
112
			CURLOPT_CAINFO         => $this->options->ca_info,
113
			CURLOPT_TIMEOUT        => $this->options->timeout,
114
			CURLOPT_CONNECTTIMEOUT => 30,
115
			CURLOPT_WRITEFUNCTION  => [$this, 'writefunction'],
116
			CURLOPT_HEADERFUNCTION => [$this, 'headerfunction'],
117
		];
118
	}
119
120
	/**
121
	 * @param array $options
122
	 *
123
	 * @return array
124
	 */
125
	protected function setBodyOptions(array $options):array{
126
		$body     = $this->request->getBody();
127
		$bodySize = $body->getSize();
128
129
		if($bodySize === 0){
130
			return $options;
131
		}
132
133
		if($body->isSeekable()){
134
			$body->rewind();
135
		}
136
137
		// Message has non empty body.
138
		if($bodySize === null || $bodySize > 1 << 20){
139
			// Avoid full loading large or unknown size body into memory
140
			$options[CURLOPT_UPLOAD] = true;
141
142
			if($bodySize !== null){
143
				$options[CURLOPT_INFILESIZE] = $bodySize;
144
			}
145
146
			$options[CURLOPT_READFUNCTION] = [$this, 'readfunction'];
147
		}
148
		// Small body can be loaded into memory
149
		else{
150
			$options[CURLOPT_POSTFIELDS] = (string)$body;
151
		}
152
153
		return $options;
154
	}
155
156
	/**
157
	 * @param array $options
158
	 *
159
	 * @return array
160
	 */
161
	protected function setHeaderOptions(array $options):array{
162
		$headers = [];
163
164
		foreach($this->request->getHeaders() as $name => $values){
165
			$header = strtolower($name);
166
167
			// curl-client does not support "Expect-Continue", so dropping "expect" headers
168
			if($header === 'expect'){
169
				continue;
170
			}
171
172
			if($header === 'content-length'){
173
174
				// Small body content length can be calculated here.
175
				if(array_key_exists(CURLOPT_POSTFIELDS, $options)){
176
					$values = [strlen($options[CURLOPT_POSTFIELDS])];
177
				}
178
				// Else if there is no body, forcing "Content-length" to 0
179
				elseif(!array_key_exists(CURLOPT_READFUNCTION, $options)){
180
					$values = ['0'];
181
				}
182
183
			}
184
185
			foreach($values as $value){
186
				$value = (string)$value;
187
188
				// cURL requires a special format for empty headers.
189
				// See https://github.com/guzzle/guzzle/issues/1882 for more details.
190
				$headers[] = $value === '' ? $name.';' : $name.': '.$value;
191
			}
192
193
		}
194
195
		$options[CURLOPT_HTTPHEADER] = $headers;
196
197
		// If the Expect header is not present, prevent curl from adding it
198
		if(!$this->request->hasHeader('Expect')){
199
			$options[CURLOPT_HTTPHEADER][] = 'Expect:';
200
		}
201
202
		// cURL sometimes adds a content-type by default. Prevent this.
203
		if(!$this->request->hasHeader('Content-Type')){
204
			$options[CURLOPT_HTTPHEADER][] = 'Content-Type:';
205
		}
206
207
		return $options;
208
	}
209
210
	/**
211
	 * @return \chillerlan\HTTP\CurlUtils\CurlHandleInterface
212
	 */
213
	public function init():CurlHandleInterface{
214
		$options  = $this->initCurlOptions();
215
		$userinfo = $this->request->getUri()->getUserInfo();
216
		$method   = $this->request->getMethod();
217
218
		if(!empty($userinfo)){
219
			$options[CURLOPT_USERPWD] = $userinfo;
220
		}
221
222
		/*
223
		 * Some HTTP methods cannot have payload:
224
		 *
225
		 * - GET   — cURL will automatically change the method to PUT or POST
226
		 *           if we set CURLOPT_UPLOAD or CURLOPT_POSTFIELDS.
227
		 * - HEAD  — cURL treats HEAD as a GET request with same restrictions.
228
		 * - TRACE — According to RFC7231: a client MUST NOT send a message body in a TRACE request.
229
		 */
230
231
		if(in_array($method, ['DELETE', 'PATCH', 'POST', 'PUT'], true)){
232
			$options = $this->setBodyOptions($options);
233
		}
234
235
		// This will set HTTP method to "HEAD".
236
		if($method === 'HEAD'){
237
			$options[CURLOPT_NOBODY] = true;
238
		}
239
240
		// GET is a default method. Other methods should be specified explicitly.
241
		if($method !== 'GET'){
242
			$options[CURLOPT_CUSTOMREQUEST] = $method;
243
		}
244
245
		// overwrite the default values with $curl_options
246
		foreach($this->options->curl_options as $k => $v){
247
			// skip some options that are only set automatically
248
			if(in_array($k, [CURLOPT_HTTPHEADER, CURLOPT_CUSTOMREQUEST, CURLOPT_NOBODY], true)){
249
				continue;
250
			}
251
252
			$options[$k] = $v;
253
		}
254
255
		$options = $this->setHeaderOptions($options);
256
257
		curl_setopt_array($this->curl, $options);
258
259
		return $this;
260
	}
261
262
	/**
263
	 * @internal
264
	 *
265
	 * @param resource $curl
266
	 * @param resource $stream
267
	 * @param int      $length
268
	 *
269
	 * @return string
270
	 */
271
	public function readfunction($curl, $stream, int $length):string{
272
		return $this->request->getBody()->read($length);
273
	}
274
275
	/**
276
	 * @internal
277
	 *
278
	 * @param resource $curl
279
	 * @param string   $data
280
	 *
281
	 * @return int
282
	 */
283
	public function writefunction($curl, string $data):int{
284
		return $this->response->getBody()->write($data);
285
	}
286
287
	/**
288
	 * @internal
289
	 *
290
	 * @param resource $curl
291
	 * @param string   $line
292
	 *
293
	 * @return int
294
	 */
295
	public function headerfunction($curl, string $line):int{
296
		$str    = trim($line);
297
		$header = explode(':', $str, 2);
298
299
		if(count($header) === 2){
300
			$this->response = $this->response
301
				->withAddedHeader(trim($header[0]), trim($header[1]));
302
		}
303
		elseif(substr(strtoupper($str), 0, 5) === 'HTTP/'){
304
			$status = explode(' ', $str, 3);
305
			$reason = count($status) > 2 ? trim($status[2]) : '';
306
307
			$this->response = $this->response
308
				->withStatus((int)$status[1], $reason)
309
				->withProtocolVersion(substr($status[0], 5))
310
			;
311
		}
312
313
		return strlen($line);
314
	}
315
316
}
317