1: <?php
2: /**
3: * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
4: * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
5: *
6: * Licensed under The MIT License
7: * Redistributions of files must retain the above copyright notice.
8: *
9: * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
10: * @link https://cakephp.org CakePHP(tm) Project
11: * @since 3.0.0
12: * @license https://opensource.org/licenses/mit-license.php MIT License
13: */
14: namespace Cake\Http;
15:
16: use Cake\Core\App;
17: use Cake\Core\Exception\Exception;
18: use Cake\Core\InstanceConfigTrait;
19: use Cake\Http\Client\AdapterInterface;
20: use Cake\Http\Client\Adapter\Curl;
21: use Cake\Http\Client\Adapter\Stream;
22: use Cake\Http\Client\Request;
23: use Cake\Http\Cookie\CookieCollection;
24: use Cake\Http\Cookie\CookieInterface;
25: use Cake\Utility\Hash;
26: use InvalidArgumentException;
27: use Zend\Diactoros\Uri;
28:
29: /**
30: * The end user interface for doing HTTP requests.
31: *
32: * ### Scoped clients
33: *
34: * If you're doing multiple requests to the same hostname it's often convenient
35: * to use the constructor arguments to create a scoped client. This allows you
36: * to keep your code DRY and not repeat hostnames, authentication, and other options.
37: *
38: * ### Doing requests
39: *
40: * Once you've created an instance of Client you can do requests
41: * using several methods. Each corresponds to a different HTTP method.
42: *
43: * - get()
44: * - post()
45: * - put()
46: * - delete()
47: * - patch()
48: *
49: * ### Cookie management
50: *
51: * Client will maintain cookies from the responses done with
52: * a client instance. These cookies will be automatically added
53: * to future requests to matching hosts. Cookies will respect the
54: * `Expires`, `Path` and `Domain` attributes. You can get the client's
55: * CookieCollection using cookies()
56: *
57: * You can use the 'cookieJar' constructor option to provide a custom
58: * cookie jar instance you've restored from cache/disk. By default
59: * an empty instance of Cake\Http\Client\CookieCollection will be created.
60: *
61: * ### Sending request bodies
62: *
63: * By default any POST/PUT/PATCH/DELETE request with $data will
64: * send their data as `application/x-www-form-urlencoded` unless
65: * there are attached files. In that case `multipart/form-data`
66: * will be used.
67: *
68: * When sending request bodies you can use the `type` option to
69: * set the Content-Type for the request:
70: *
71: * ```
72: * $http->get('/users', [], ['type' => 'json']);
73: * ```
74: *
75: * The `type` option sets both the `Content-Type` and `Accept` header, to
76: * the same mime type. When using `type` you can use either a full mime
77: * type or an alias. If you need different types in the Accept and Content-Type
78: * headers you should set them manually and not use `type`
79: *
80: * ### Using authentication
81: *
82: * By using the `auth` key you can use authentication. The type sub option
83: * can be used to specify which authentication strategy you want to use.
84: * CakePHP comes with a few built-in strategies:
85: *
86: * - Basic
87: * - Digest
88: * - Oauth
89: *
90: * ### Using proxies
91: *
92: * By using the `proxy` key you can set authentication credentials for
93: * a proxy if you need to use one. The type sub option can be used to
94: * specify which authentication strategy you want to use.
95: * CakePHP comes with built-in support for basic authentication.
96: */
97: class Client
98: {
99: use InstanceConfigTrait;
100:
101: /**
102: * Default configuration for the client.
103: *
104: * @var array
105: */
106: protected $_defaultConfig = [
107: 'adapter' => null,
108: 'host' => null,
109: 'port' => null,
110: 'scheme' => 'http',
111: 'timeout' => 30,
112: 'ssl_verify_peer' => true,
113: 'ssl_verify_peer_name' => true,
114: 'ssl_verify_depth' => 5,
115: 'ssl_verify_host' => true,
116: 'redirect' => false,
117: ];
118:
119: /**
120: * List of cookies from responses made with this client.
121: *
122: * Cookies are indexed by the cookie's domain or
123: * request host name.
124: *
125: * @var \Cake\Http\Cookie\CookieCollection
126: */
127: protected $_cookies;
128:
129: /**
130: * Adapter for sending requests.
131: *
132: * @var \Cake\Http\Client\AdapterInterface
133: */
134: protected $_adapter;
135:
136: /**
137: * Create a new HTTP Client.
138: *
139: * ### Config options
140: *
141: * You can set the following options when creating a client:
142: *
143: * - host - The hostname to do requests on.
144: * - port - The port to use.
145: * - scheme - The default scheme/protocol to use. Defaults to http.
146: * - timeout - The timeout in seconds. Defaults to 30
147: * - ssl_verify_peer - Whether or not SSL certificates should be validated.
148: * Defaults to true.
149: * - ssl_verify_peer_name - Whether or not peer names should be validated.
150: * Defaults to true.
151: * - ssl_verify_depth - The maximum certificate chain depth to traverse.
152: * Defaults to 5.
153: * - ssl_verify_host - Verify that the certificate and hostname match.
154: * Defaults to true.
155: * - redirect - Number of redirects to follow. Defaults to false.
156: * - adapter - The adapter class name or instance. Defaults to
157: * \Cake\Http\Client\Adapter\Curl if `curl` extension is loaded else
158: * \Cake\Http\Client\Adapter\Stream.
159: *
160: * @param array $config Config options for scoped clients.
161: */
162: public function __construct($config = [])
163: {
164: $this->setConfig($config);
165:
166: $adapter = $this->_config['adapter'];
167: if ($adapter === null) {
168: $adapter = Curl::class;
169:
170: if (!extension_loaded('curl')) {
171: $adapter = Stream::class;
172: }
173: } else {
174: $this->setConfig('adapter', null);
175: }
176:
177: if (is_string($adapter)) {
178: $adapter = new $adapter();
179: }
180:
181: if (!$adapter instanceof AdapterInterface) {
182: throw new InvalidArgumentException('Adapter must be an instance of Cake\Http\Client\AdapterInterface');
183: }
184: $this->_adapter = $adapter;
185:
186: if (!empty($this->_config['cookieJar'])) {
187: $this->_cookies = $this->_config['cookieJar'];
188: $this->setConfig('cookieJar', null);
189: } else {
190: $this->_cookies = new CookieCollection();
191: }
192: }
193:
194: /**
195: * Get the cookies stored in the Client.
196: *
197: * @return \Cake\Http\Cookie\CookieCollection
198: */
199: public function cookies()
200: {
201: return $this->_cookies;
202: }
203:
204: /**
205: * Adds a cookie to the Client collection.
206: *
207: * @param \Cake\Http\Cookie\CookieInterface $cookie Cookie object.
208: * @return $this
209: */
210: public function addCookie(CookieInterface $cookie)
211: {
212: if (!$cookie->getDomain() || !$cookie->getPath()) {
213: throw new InvalidArgumentException('Cookie must have a domain and a path set.');
214: }
215: $this->_cookies = $this->_cookies->add($cookie);
216:
217: return $this;
218: }
219:
220: /**
221: * Do a GET request.
222: *
223: * The $data argument supports a special `_content` key
224: * for providing a request body in a GET request. This is
225: * generally not used, but services like ElasticSearch use
226: * this feature.
227: *
228: * @param string $url The url or path you want to request.
229: * @param array $data The query data you want to send.
230: * @param array $options Additional options for the request.
231: * @return \Cake\Http\Client\Response
232: */
233: public function get($url, $data = [], array $options = [])
234: {
235: $options = $this->_mergeOptions($options);
236: $body = null;
237: if (isset($data['_content'])) {
238: $body = $data['_content'];
239: unset($data['_content']);
240: }
241: $url = $this->buildUrl($url, $data, $options);
242:
243: return $this->_doRequest(
244: Request::METHOD_GET,
245: $url,
246: $body,
247: $options
248: );
249: }
250:
251: /**
252: * Do a POST request.
253: *
254: * @param string $url The url or path you want to request.
255: * @param mixed $data The post data you want to send.
256: * @param array $options Additional options for the request.
257: * @return \Cake\Http\Client\Response
258: */
259: public function post($url, $data = [], array $options = [])
260: {
261: $options = $this->_mergeOptions($options);
262: $url = $this->buildUrl($url, [], $options);
263:
264: return $this->_doRequest(Request::METHOD_POST, $url, $data, $options);
265: }
266:
267: /**
268: * Do a PUT request.
269: *
270: * @param string $url The url or path you want to request.
271: * @param mixed $data The request data you want to send.
272: * @param array $options Additional options for the request.
273: * @return \Cake\Http\Client\Response
274: */
275: public function put($url, $data = [], array $options = [])
276: {
277: $options = $this->_mergeOptions($options);
278: $url = $this->buildUrl($url, [], $options);
279:
280: return $this->_doRequest(Request::METHOD_PUT, $url, $data, $options);
281: }
282:
283: /**
284: * Do a PATCH request.
285: *
286: * @param string $url The url or path you want to request.
287: * @param mixed $data The request data you want to send.
288: * @param array $options Additional options for the request.
289: * @return \Cake\Http\Client\Response
290: */
291: public function patch($url, $data = [], array $options = [])
292: {
293: $options = $this->_mergeOptions($options);
294: $url = $this->buildUrl($url, [], $options);
295:
296: return $this->_doRequest(Request::METHOD_PATCH, $url, $data, $options);
297: }
298:
299: /**
300: * Do an OPTIONS request.
301: *
302: * @param string $url The url or path you want to request.
303: * @param mixed $data The request data you want to send.
304: * @param array $options Additional options for the request.
305: * @return \Cake\Http\Client\Response
306: */
307: public function options($url, $data = [], array $options = [])
308: {
309: $options = $this->_mergeOptions($options);
310: $url = $this->buildUrl($url, [], $options);
311:
312: return $this->_doRequest(Request::METHOD_OPTIONS, $url, $data, $options);
313: }
314:
315: /**
316: * Do a TRACE request.
317: *
318: * @param string $url The url or path you want to request.
319: * @param mixed $data The request data you want to send.
320: * @param array $options Additional options for the request.
321: * @return \Cake\Http\Client\Response
322: */
323: public function trace($url, $data = [], array $options = [])
324: {
325: $options = $this->_mergeOptions($options);
326: $url = $this->buildUrl($url, [], $options);
327:
328: return $this->_doRequest(Request::METHOD_TRACE, $url, $data, $options);
329: }
330:
331: /**
332: * Do a DELETE request.
333: *
334: * @param string $url The url or path you want to request.
335: * @param mixed $data The request data you want to send.
336: * @param array $options Additional options for the request.
337: * @return \Cake\Http\Client\Response
338: */
339: public function delete($url, $data = [], array $options = [])
340: {
341: $options = $this->_mergeOptions($options);
342: $url = $this->buildUrl($url, [], $options);
343:
344: return $this->_doRequest(Request::METHOD_DELETE, $url, $data, $options);
345: }
346:
347: /**
348: * Do a HEAD request.
349: *
350: * @param string $url The url or path you want to request.
351: * @param array $data The query string data you want to send.
352: * @param array $options Additional options for the request.
353: * @return \Cake\Http\Client\Response
354: */
355: public function head($url, array $data = [], array $options = [])
356: {
357: $options = $this->_mergeOptions($options);
358: $url = $this->buildUrl($url, $data, $options);
359:
360: return $this->_doRequest(Request::METHOD_HEAD, $url, '', $options);
361: }
362:
363: /**
364: * Helper method for doing non-GET requests.
365: *
366: * @param string $method HTTP method.
367: * @param string $url URL to request.
368: * @param mixed $data The request body.
369: * @param array $options The options to use. Contains auth, proxy, etc.
370: * @return \Cake\Http\Client\Response
371: */
372: protected function _doRequest($method, $url, $data, $options)
373: {
374: $request = $this->_createRequest(
375: $method,
376: $url,
377: $data,
378: $options
379: );
380:
381: return $this->send($request, $options);
382: }
383:
384: /**
385: * Does a recursive merge of the parameter with the scope config.
386: *
387: * @param array $options Options to merge.
388: * @return array Options merged with set config.
389: */
390: protected function _mergeOptions($options)
391: {
392: return Hash::merge($this->_config, $options);
393: }
394:
395: /**
396: * Send a request.
397: *
398: * Used internally by other methods, but can also be used to send
399: * handcrafted Request objects.
400: *
401: * @param \Cake\Http\Client\Request $request The request to send.
402: * @param array $options Additional options to use.
403: * @return \Cake\Http\Client\Response
404: */
405: public function send(Request $request, $options = [])
406: {
407: $redirects = 0;
408: if (isset($options['redirect'])) {
409: $redirects = (int)$options['redirect'];
410: unset($options['redirect']);
411: }
412:
413: do {
414: $response = $this->_sendRequest($request, $options);
415:
416: $handleRedirect = $response->isRedirect() && $redirects-- > 0;
417: if ($handleRedirect) {
418: $url = $request->getUri();
419: $request = $this->_cookies->addToRequest($request, []);
420:
421: $location = $response->getHeaderLine('Location');
422: $locationUrl = $this->buildUrl($location, [], [
423: 'host' => $url->getHost(),
424: 'port' => $url->getPort(),
425: 'scheme' => $url->getScheme(),
426: 'protocolRelative' => true
427: ]);
428:
429: $request = $request->withUri(new Uri($locationUrl));
430: }
431: } while ($handleRedirect);
432:
433: return $response;
434: }
435:
436: /**
437: * Send a request without redirection.
438: *
439: * @param \Cake\Http\Client\Request $request The request to send.
440: * @param array $options Additional options to use.
441: * @return \Cake\Http\Client\Response
442: */
443: protected function _sendRequest(Request $request, $options)
444: {
445: $responses = $this->_adapter->send($request, $options);
446: $url = $request->getUri();
447: foreach ($responses as $response) {
448: $this->_cookies = $this->_cookies->addFromResponse($response, $request);
449: }
450:
451: return array_pop($responses);
452: }
453:
454: /**
455: * Generate a URL based on the scoped client options.
456: *
457: * @param string $url Either a full URL or just the path.
458: * @param string|array $query The query data for the URL.
459: * @param array $options The config options stored with Client::config()
460: * @return string A complete url with scheme, port, host, and path.
461: */
462: public function buildUrl($url, $query = [], $options = [])
463: {
464: if (empty($options) && empty($query)) {
465: return $url;
466: }
467: if ($query) {
468: $q = (strpos($url, '?') === false) ? '?' : '&';
469: $url .= $q;
470: $url .= is_string($query) ? $query : http_build_query($query);
471: }
472: $defaults = [
473: 'host' => null,
474: 'port' => null,
475: 'scheme' => 'http',
476: 'protocolRelative' => false
477: ];
478: $options += $defaults;
479:
480: if ($options['protocolRelative'] && preg_match('#^//#', $url)) {
481: $url = $options['scheme'] . ':' . $url;
482: }
483: if (preg_match('#^https?://#', $url)) {
484: return $url;
485: }
486:
487: $defaultPorts = [
488: 'http' => 80,
489: 'https' => 443
490: ];
491: $out = $options['scheme'] . '://' . $options['host'];
492: if ($options['port'] && $options['port'] != $defaultPorts[$options['scheme']]) {
493: $out .= ':' . $options['port'];
494: }
495: $out .= '/' . ltrim($url, '/');
496:
497: return $out;
498: }
499:
500: /**
501: * Creates a new request object based on the parameters.
502: *
503: * @param string $method HTTP method name.
504: * @param string $url The url including query string.
505: * @param mixed $data The request body.
506: * @param array $options The options to use. Contains auth, proxy, etc.
507: * @return \Cake\Http\Client\Request
508: */
509: protected function _createRequest($method, $url, $data, $options)
510: {
511: $headers = isset($options['headers']) ? (array)$options['headers'] : [];
512: if (isset($options['type'])) {
513: $headers = array_merge($headers, $this->_typeHeaders($options['type']));
514: }
515: if (is_string($data) && !isset($headers['Content-Type']) && !isset($headers['content-type'])) {
516: $headers['Content-Type'] = 'application/x-www-form-urlencoded';
517: }
518:
519: $request = new Request($url, $method, $headers, $data);
520: $cookies = isset($options['cookies']) ? $options['cookies'] : [];
521: /** @var \Cake\Http\Client\Request $request */
522: $request = $this->_cookies->addToRequest($request, $cookies);
523: if (isset($options['auth'])) {
524: $request = $this->_addAuthentication($request, $options);
525: }
526: if (isset($options['proxy'])) {
527: $request = $this->_addProxy($request, $options);
528: }
529:
530: return $request;
531: }
532:
533: /**
534: * Returns headers for Accept/Content-Type based on a short type
535: * or full mime-type.
536: *
537: * @param string $type short type alias or full mimetype.
538: * @return array Headers to set on the request.
539: * @throws \Cake\Core\Exception\Exception When an unknown type alias is used.
540: */
541: protected function _typeHeaders($type)
542: {
543: if (strpos($type, '/') !== false) {
544: return [
545: 'Accept' => $type,
546: 'Content-Type' => $type
547: ];
548: }
549: $typeMap = [
550: 'json' => 'application/json',
551: 'xml' => 'application/xml',
552: ];
553: if (!isset($typeMap[$type])) {
554: throw new Exception("Unknown type alias '$type'.");
555: }
556:
557: return [
558: 'Accept' => $typeMap[$type],
559: 'Content-Type' => $typeMap[$type],
560: ];
561: }
562:
563: /**
564: * Add authentication headers to the request.
565: *
566: * Uses the authentication type to choose the correct strategy
567: * and use its methods to add headers.
568: *
569: * @param \Cake\Http\Client\Request $request The request to modify.
570: * @param array $options Array of options containing the 'auth' key.
571: * @return \Cake\Http\Client\Request The updated request object.
572: */
573: protected function _addAuthentication(Request $request, $options)
574: {
575: $auth = $options['auth'];
576: $adapter = $this->_createAuth($auth, $options);
577: $result = $adapter->authentication($request, $options['auth']);
578:
579: return $result ?: $request;
580: }
581:
582: /**
583: * Add proxy authentication headers.
584: *
585: * Uses the authentication type to choose the correct strategy
586: * and use its methods to add headers.
587: *
588: * @param \Cake\Http\Client\Request $request The request to modify.
589: * @param array $options Array of options containing the 'proxy' key.
590: * @return \Cake\Http\Client\Request The updated request object.
591: */
592: protected function _addProxy(Request $request, $options)
593: {
594: $auth = $options['proxy'];
595: $adapter = $this->_createAuth($auth, $options);
596: $result = $adapter->proxyAuthentication($request, $options['proxy']);
597:
598: return $result ?: $request;
599: }
600:
601: /**
602: * Create the authentication strategy.
603: *
604: * Use the configuration options to create the correct
605: * authentication strategy handler.
606: *
607: * @param array $auth The authentication options to use.
608: * @param array $options The overall request options to use.
609: * @return mixed Authentication strategy instance.
610: * @throws \Cake\Core\Exception\Exception when an invalid strategy is chosen.
611: */
612: protected function _createAuth($auth, $options)
613: {
614: if (empty($auth['type'])) {
615: $auth['type'] = 'basic';
616: }
617: $name = ucfirst($auth['type']);
618: $class = App::className($name, 'Http/Client/Auth');
619: if (!$class) {
620: throw new Exception(
621: sprintf('Invalid authentication type %s', $name)
622: );
623: }
624:
625: return new $class($this, $options);
626: }
627: }
628: // @deprecated 3.4.0 Backwards compatibility with earler 3.x versions.
629: class_alias('Cake\Http\Client', 'Cake\Network\Http\Client');
630: