PluginProbe
Plausible Analytics / trunk
Plausible Analytics vtrunk
2.6.1 2.6.0 trunk 1.0.0 1.0.1 1.1.0 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 2.0.0 2.0.1 2.0.2 2.0.3 2.0.4 All 52 releases
plausible-analytics / src / Client / lib / Lib / GuzzleHttp / Client.php

Client.php in Plausible Analytics trunk, at src/Client/lib/Lib/GuzzleHttp/Client.php

454 lines 16.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Plausible\Analytics\WP\Client\Lib\GuzzleHttp;
4
5 use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Cookie\CookieJar;
6 use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Exception\GuzzleException;
7 use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Exception\InvalidArgumentException;
8 use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise as P;
9 use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise\PromiseInterface;
10 use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\RequestInterface;
11 use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\ResponseInterface;
12 use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\UriInterface;
13
14 /**
15 * @final
16 */
17 class Client implements ClientInterface, \Plausible\Analytics\WP\Client\Lib\Psr\Http\Client\ClientInterface {
18 use ClientTrait;
19
20 /**
21 * @var array Default request options
22 */
23 private $config;
24
25 /**
26 * Clients accept an array of constructor parameters.
27 * Here's an example of creating a client using a base_uri and an array of
28 * default request options to apply to each request:
29 * $client = new Client([
30 * 'base_uri' => 'http://www.foo.com/1.0/',
31 * 'timeout' => 0,
32 * 'allow_redirects' => false,
33 * 'proxy' => '192.168.16.1:10'
34 * ]);
35 * Client configuration settings include the following options:
36 * - handler: (callable) Function that transfers HTTP requests over the
37 * wire. The function is called with a Psr7\Http\Message\RequestInterface
38 * and array of transfer options, and must return a
39 * Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise\PromiseInterface that is fulfilled with a
40 * Psr7\Http\Message\ResponseInterface on success.
41 * If no handler is provided, a default handler will be created
42 * that enables all of the request options below by attaching all of the
43 * default middleware to the handler.
44 * - base_uri: (string|UriInterface) Base URI of the client that is merged
45 * into relative URIs. Can be a string or instance of UriInterface.
46 * - **: any request option
47 * @see \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\RequestOptions for a list of available request options.
48 *
49 * @param array $config Client configuration settings.
50 */
51 public function __construct( array $config = [] ) {
52 if ( ! isset( $config[ 'handler' ] ) ) {
53 $config[ 'handler' ] = HandlerStack::create();
54 } elseif ( ! \is_callable( $config[ 'handler' ] ) ) {
55 throw new InvalidArgumentException( 'handler must be a callable' );
56 }
57
58 // Convert the base_uri to a UriInterface
59 if ( isset( $config[ 'base_uri' ] ) ) {
60 $config[ 'base_uri' ] = Psr7\Utils::uriFor( $config[ 'base_uri' ] );
61 }
62
63 $this->configureDefaults( $config );
64 }
65
66 /**
67 * Configures the default options for a client.
68 */
69 private function configureDefaults( array $config ): void {
70 $defaults = [
71 'allow_redirects' => RedirectMiddleware::$defaultSettings,
72 'http_errors' => true,
73 'decode_content' => true,
74 'verify' => true,
75 'cookies' => false,
76 'idn_conversion' => false,
77 ];
78
79 // Use the standard Linux HTTP_PROXY and HTTPS_PROXY if set.
80
81 // We can only trust the HTTP_PROXY environment variable in a CLI
82 // process due to the fact that PHP has no reliable mechanism to
83 // get environment variables that start with "HTTP_".
84 if ( \PHP_SAPI === 'cli' && ( $proxy = Utils::getenv( 'HTTP_PROXY' ) ) ) {
85 $defaults[ 'proxy' ][ 'http' ] = $proxy;
86 }
87
88 if ( $proxy = Utils::getenv( 'HTTPS_PROXY' ) ) {
89 $defaults[ 'proxy' ][ 'https' ] = $proxy;
90 }
91
92 if ( $noProxy = Utils::getenv( 'NO_PROXY' ) ) {
93 $cleanedNoProxy = \str_replace( ' ', '', $noProxy );
94 $defaults[ 'proxy' ][ 'no' ] = \explode( ',', $cleanedNoProxy );
95 }
96
97 $this->config = $config + $defaults;
98
99 if ( ! empty( $config[ 'cookies' ] ) && $config[ 'cookies' ] === true ) {
100 $this->config[ 'cookies' ] = new CookieJar();
101 }
102
103 // Add the default user-agent header.
104 if ( ! isset( $this->config[ 'headers' ] ) ) {
105 $this->config[ 'headers' ] = [ 'User-Agent' => Utils::defaultUserAgent() ];
106 } else {
107 // Add the User-Agent header if one was not already set.
108 foreach ( \array_keys( $this->config[ 'headers' ] ) as $name ) {
109 if ( \strtolower( $name ) === 'user-agent' ) {
110 return;
111 }
112 }
113 $this->config[ 'headers' ][ 'User-Agent' ] = Utils::defaultUserAgent();
114 }
115 }
116
117 /**
118 * @param string $method
119 * @param array $args
120 *
121 * @return PromiseInterface|ResponseInterface
122 * @deprecated Client::__call will be removed in Plausible\Analytics\WP\Client\Lib\GuzzleHttp/guzzle:8.0.
123 */
124 public function __call( $method, $args ) {
125 if ( \count( $args ) < 1 ) {
126 throw new InvalidArgumentException( 'Magic request methods require a URI and optional options array' );
127 }
128
129 $uri = $args[ 0 ];
130 $opts = $args[ 1 ] ?? [];
131
132 return \substr( $method, - 5 ) === 'Async' ? $this->requestAsync( \substr( $method, 0, - 5 ), $uri, $opts ) :
133 $this->request( $method, $uri, $opts );
134 }
135
136 /**
137 * Create and send an asynchronous HTTP request.
138 * Use an absolute path to override the base path of the client, or a
139 * relative path to append to the base path of the client. The URL can
140 * contain the query string as well. Use an array to provide a URL
141 * template and additional variables to use in the URL template expansion.
142 *
143 * @param string $method HTTP method
144 * @param string|UriInterface $uri URI object or string.
145 * @param array $options Request options to apply. See \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\RequestOptions.
146 */
147 public function requestAsync( string $method, $uri = '', array $options = [] ): PromiseInterface {
148 $options = $this->prepareDefaults( $options );
149 // Remove request modifying parameter because it can be done up-front.
150 $headers = $options[ 'headers' ] ?? [];
151 $body = $options[ 'body' ] ?? null;
152 $version = $options[ 'version' ] ?? '1.1';
153 // Merge the URI into the base URI.
154 $uri = $this->buildUri( Psr7\Utils::uriFor( $uri ), $options );
155 if ( \is_array( $body ) ) {
156 throw $this->invalidBody();
157 }
158 $request = new Psr7\Request( $method, $uri, $headers, $body, $version );
159 // Remove the option so that they are not doubly-applied.
160 unset( $options[ 'headers' ], $options[ 'body' ], $options[ 'version' ] );
161
162 return $this->transfer( $request, $options );
163 }
164
165 /**
166 * Merges default options into the array.
167 *
168 * @param array $options Options to modify by reference
169 */
170 private function prepareDefaults( array $options ): array {
171 $defaults = $this->config;
172
173 if ( ! empty( $defaults[ 'headers' ] ) ) {
174 // Default headers are only added if they are not present.
175 $defaults[ '_conditional' ] = $defaults[ 'headers' ];
176 unset( $defaults[ 'headers' ] );
177 }
178
179 // Special handling for headers is required as they are added as
180 // conditional headers and as headers passed to a request ctor.
181 if ( \array_key_exists( 'headers', $options ) ) {
182 // Allows default headers to be unset.
183 if ( $options[ 'headers' ] === null ) {
184 $defaults[ '_conditional' ] = [];
185 unset( $options[ 'headers' ] );
186 } elseif ( ! \is_array( $options[ 'headers' ] ) ) {
187 throw new InvalidArgumentException( 'headers must be an array' );
188 }
189 }
190
191 // Shallow merge defaults underneath options.
192 $result = $options + $defaults;
193
194 // Remove null values.
195 foreach ( $result as $k => $v ) {
196 if ( $v === null ) {
197 unset( $result[ $k ] );
198 }
199 }
200
201 return $result;
202 }
203
204 private function buildUri( UriInterface $uri, array $config ): UriInterface {
205 if ( isset( $config[ 'base_uri' ] ) ) {
206 $uri = Psr7\UriResolver::resolve( Psr7\Utils::uriFor( $config[ 'base_uri' ] ), $uri );
207 }
208
209 if ( isset( $config[ 'idn_conversion' ] ) && ( $config[ 'idn_conversion' ] !== false ) ) {
210 $idnOptions = ( $config[ 'idn_conversion' ] === true ) ? \IDNA_DEFAULT : $config[ 'idn_conversion' ];
211 $uri = Utils::idnUriConvert( $uri, $idnOptions );
212 }
213
214 return $uri->getScheme() === '' && $uri->getHost() !== '' ? $uri->withScheme( 'http' ) : $uri;
215 }
216
217 /**
218 * Return an InvalidArgumentException with pre-set message.
219 */
220 private function invalidBody(): InvalidArgumentException {
221 return new InvalidArgumentException(
222 'Passing in the "body" request ' .
223 'option as an array to send a request is not supported. ' .
224 'Please use the "form_params" request option to send a ' .
225 'application/x-www-form-urlencoded request, or the "multipart" ' .
226 'request option to send a multipart/form-data request.'
227 );
228 }
229
230 /**
231 * Transfers the given request and applies request options.
232 * The URI of the request is not modified and the request options are used
233 * as-is without merging in default options.
234 *
235 * @param array $options See \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\RequestOptions.
236 */
237 private function transfer( RequestInterface $request, array $options ): PromiseInterface {
238 $request = $this->applyOptions( $request, $options );
239 /** @var HandlerStack $handler */
240 $handler = $options[ 'handler' ];
241
242 try {
243 return P\Create::promiseFor( $handler( $request, $options ) );
244 } catch ( \Exception $e ) {
245 return P\Create::rejectionFor( $e );
246 }
247 }
248
249 /**
250 * Applies the array of request options to a request.
251 */
252 private function applyOptions( RequestInterface $request, array &$options ): RequestInterface {
253 $modify = [
254 'set_headers' => [],
255 ];
256
257 if ( isset( $options[ 'headers' ] ) ) {
258 if ( array_keys( $options[ 'headers' ] ) === range( 0, count( $options[ 'headers' ] ) - 1 ) ) {
259 throw new InvalidArgumentException( 'The headers array must have header name as keys.' );
260 }
261 $modify[ 'set_headers' ] = $options[ 'headers' ];
262 unset( $options[ 'headers' ] );
263 }
264
265 if ( isset( $options[ 'form_params' ] ) ) {
266 if ( isset( $options[ 'multipart' ] ) ) {
267 throw new InvalidArgumentException(
268 'You cannot use ' .
269 'form_params and multipart at the same time. Use the ' .
270 'form_params option if you want to send application/' .
271 'x-www-form-urlencoded requests, and the multipart ' .
272 'option to send multipart/form-data requests.'
273 );
274 }
275 $options[ 'body' ] = \http_build_query( $options[ 'form_params' ], '', '&' );
276 unset( $options[ 'form_params' ] );
277 // Ensure that we don't have the header in different case and set the new value.
278 $options[ '_conditional' ] = Psr7\Utils::caselessRemove( [ 'Content-Type' ], $options[ '_conditional' ] );
279 $options[ '_conditional' ][ 'Content-Type' ] = 'application/x-www-form-urlencoded';
280 }
281
282 if ( isset( $options[ 'multipart' ] ) ) {
283 $options[ 'body' ] = new Psr7\MultipartStream( $options[ 'multipart' ] );
284 unset( $options[ 'multipart' ] );
285 }
286
287 if ( isset( $options[ 'json' ] ) ) {
288 $options[ 'body' ] = Utils::jsonEncode( $options[ 'json' ] );
289 unset( $options[ 'json' ] );
290 // Ensure that we don't have the header in different case and set the new value.
291 $options[ '_conditional' ] = Psr7\Utils::caselessRemove( [ 'Content-Type' ], $options[ '_conditional' ] );
292 $options[ '_conditional' ][ 'Content-Type' ] = 'application/json';
293 }
294
295 if ( ! empty( $options[ 'decode_content' ] ) && $options[ 'decode_content' ] !== true ) {
296 // Ensure that we don't have the header in different case and set the new value.
297 $options[ '_conditional' ] = Psr7\Utils::caselessRemove( [ 'Accept-Encoding' ], $options[ '_conditional' ] );
298 $modify[ 'set_headers' ][ 'Accept-Encoding' ] = $options[ 'decode_content' ];
299 }
300
301 if ( isset( $options[ 'body' ] ) ) {
302 if ( \is_array( $options[ 'body' ] ) ) {
303 throw $this->invalidBody();
304 }
305 $modify[ 'body' ] = Psr7\Utils::streamFor( $options[ 'body' ] );
306 unset( $options[ 'body' ] );
307 }
308
309 if ( ! empty( $options[ 'auth' ] ) && \is_array( $options[ 'auth' ] ) ) {
310 $value = $options[ 'auth' ];
311 $type = isset( $value[ 2 ] ) ? \strtolower( $value[ 2 ] ) : 'basic';
312 switch ( $type ) {
313 case 'basic':
314 // Ensure that we don't have the header in different case and set the new value.
315 $modify[ 'set_headers' ] = Psr7\Utils::caselessRemove( [ 'Authorization' ], $modify[ 'set_headers' ] );
316 $modify[ 'set_headers' ][ 'Authorization' ] = 'Basic ' . \base64_encode( "$value[0]:$value[1]" );
317 break;
318 case 'digest':
319 // @todo: Do not rely on curl
320 $options[ 'curl' ][ \CURLOPT_HTTPAUTH ] = \CURLAUTH_DIGEST;
321 $options[ 'curl' ][ \CURLOPT_USERPWD ] = "$value[0]:$value[1]";
322 break;
323 case 'ntlm':
324 $options[ 'curl' ][ \CURLOPT_HTTPAUTH ] = \CURLAUTH_NTLM;
325 $options[ 'curl' ][ \CURLOPT_USERPWD ] = "$value[0]:$value[1]";
326 break;
327 }
328 }
329
330 if ( isset( $options[ 'query' ] ) ) {
331 $value = $options[ 'query' ];
332 if ( \is_array( $value ) ) {
333 $value = \http_build_query( $value, '', '&', \PHP_QUERY_RFC3986 );
334 }
335 if ( ! \is_string( $value ) ) {
336 throw new InvalidArgumentException( 'query must be a string or array' );
337 }
338 $modify[ 'query' ] = $value;
339 unset( $options[ 'query' ] );
340 }
341
342 // Ensure that sink is not an invalid value.
343 if ( isset( $options[ 'sink' ] ) ) {
344 // TODO: Add more sink validation?
345 if ( \is_bool( $options[ 'sink' ] ) ) {
346 throw new InvalidArgumentException( 'sink must not be a boolean' );
347 }
348 }
349
350 if ( isset( $options[ 'version' ] ) ) {
351 $modify[ 'version' ] = $options[ 'version' ];
352 }
353
354 $request = Psr7\Utils::modifyRequest( $request, $modify );
355 if ( $request->getBody() instanceof Psr7\MultipartStream ) {
356 // Use a multipart/form-data POST if a Content-Type is not set.
357 // Ensure that we don't have the header in different case and set the new value.
358 $options[ '_conditional' ] = Psr7\Utils::caselessRemove( [ 'Content-Type' ], $options[ '_conditional' ] );
359 $options[ '_conditional' ][ 'Content-Type' ] = 'multipart/form-data; boundary=' . $request->getBody()->getBoundary();
360 }
361
362 // Merge in conditional headers if they are not present.
363 if ( isset( $options[ '_conditional' ] ) ) {
364 // Build up the changes so it's in a single clone of the message.
365 $modify = [];
366 foreach ( $options[ '_conditional' ] as $k => $v ) {
367 if ( ! $request->hasHeader( $k ) ) {
368 $modify[ 'set_headers' ][ $k ] = $v;
369 }
370 }
371 $request = Psr7\Utils::modifyRequest( $request, $modify );
372 // Don't pass this internal value along to middleware/handlers.
373 unset( $options[ '_conditional' ] );
374 }
375
376 return $request;
377 }
378
379 /**
380 * Create and send an HTTP request.
381 * Use an absolute path to override the base path of the client, or a
382 * relative path to append to the base path of the client. The URL can
383 * contain the query string as well.
384 *
385 * @param string $method HTTP method.
386 * @param string|UriInterface $uri URI object or string.
387 * @param array $options Request options to apply. See \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\RequestOptions.
388 *
389 * @throws GuzzleException
390 */
391 public function request( string $method, $uri = '', array $options = [] ): ResponseInterface {
392 $options[ RequestOptions::SYNCHRONOUS ] = true;
393
394 return $this->requestAsync( $method, $uri, $options )->wait();
395 }
396
397 /**
398 * Send an HTTP request.
399 *
400 * @param array $options Request options to apply to the given
401 * request and to the transfer. See \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\RequestOptions.
402 *
403 * @throws GuzzleException
404 */
405 public function send( RequestInterface $request, array $options = [] ): ResponseInterface {
406 $options[ RequestOptions::SYNCHRONOUS ] = true;
407
408 return $this->sendAsync( $request, $options )->wait();
409 }
410
411 /**
412 * Asynchronously send an HTTP request.
413 *
414 * @param array $options Request options to apply to the given
415 * request and to the transfer. See \Plausible\Analytics\WP\Client\Lib\GuzzleHttp\RequestOptions.
416 */
417 public function sendAsync( RequestInterface $request, array $options = [] ): PromiseInterface {
418 // Merge the base URI into the request URI if needed.
419 $options = $this->prepareDefaults( $options );
420
421 return $this->transfer(
422 $request->withUri( $this->buildUri( $request->getUri(), $options ), $request->hasHeader( 'Host' ) ),
423 $options
424 );
425 }
426
427 /**
428 * The HttpClient PSR (PSR-18) specify this method.
429 * {@inheritDoc}
430 */
431 public function sendRequest( RequestInterface $request ): ResponseInterface {
432 $options[ RequestOptions::SYNCHRONOUS ] = true;
433 $options[ RequestOptions::ALLOW_REDIRECTS ] = false;
434 $options[ RequestOptions::HTTP_ERRORS ] = false;
435
436 return $this->sendAsync( $request, $options )->wait();
437 }
438
439 /**
440 * Get a client configuration option.
441 * These options include default request options of the client, a "handler"
442 * (if utilized by the concrete client), and a "base_uri" if utilized by
443 * the concrete client.
444 *
445 * @param string|null $option The config option to retrieve.
446 *
447 * @return mixed
448 * @deprecated Client::getConfig will be removed in Plausible\Analytics\WP\Client\Lib\GuzzleHttp/guzzle:8.0.
449 */
450 public function getConfig( ?string $option = null ) {
451 return $option === null ? $this->config : ( $this->config[ $option ] ?? null );
452 }
453 }
454