<?php
/**
 * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
 * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 *
 * Licensed under The MIT License
 * Redistributions of files must retain the above copyright notice.
 *
 * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 * @link          https://cakephp.org CakePHP(tm) Project
 * @since         3.0.0
 * @license       https://opensource.org/licenses/mit-license.php MIT License
 */
namespace Cake\Http;

use Cake\Core\App;
use Cake\Core\Exception\Exception;
use Cake\Core\InstanceConfigTrait;
use Cake\Http\Client\AdapterInterface;
use Cake\Http\Client\Adapter\Curl;
use Cake\Http\Client\Adapter\Stream;
use Cake\Http\Client\Request;
use Cake\Http\Cookie\CookieCollection;
use Cake\Http\Cookie\CookieInterface;
use Cake\Utility\Hash;
use InvalidArgumentException;
use Laminas\Diactoros\Uri;

/**
 * The end user interface for doing HTTP requests.
 *
 * ### Scoped clients
 *
 * If you're doing multiple requests to the same hostname it's often convenient
 * to use the constructor arguments to create a scoped client. This allows you
 * to keep your code DRY and not repeat hostnames, authentication, and other options.
 *
 * ### Doing requests
 *
 * Once you've created an instance of Client you can do requests
 * using several methods. Each corresponds to a different HTTP method.
 *
 * - get()
 * - post()
 * - put()
 * - delete()
 * - patch()
 *
 * ### Cookie management
 *
 * Client will maintain cookies from the responses done with
 * a client instance. These cookies will be automatically added
 * to future requests to matching hosts. Cookies will respect the
 * `Expires`, `Path` and `Domain` attributes. You can get the client's
 * CookieCollection using cookies()
 *
 * You can use the 'cookieJar' constructor option to provide a custom
 * cookie jar instance you've restored from cache/disk. By default
 * an empty instance of Cake\Http\Client\CookieCollection will be created.
 *
 * ### Sending request bodies
 *
 * By default any POST/PUT/PATCH/DELETE request with $data will
 * send their data as `application/x-www-form-urlencoded` unless
 * there are attached files. In that case `multipart/form-data`
 * will be used.
 *
 * When sending request bodies you can use the `type` option to
 * set the Content-Type for the request:
 *
 * ```
 * $http->get('/users', [], ['type' => 'json']);
 * ```
 *
 * The `type` option sets both the `Content-Type` and `Accept` header, to
 * the same mime type. When using `type` you can use either a full mime
 * type or an alias. If you need different types in the Accept and Content-Type
 * headers you should set them manually and not use `type`
 *
 * ### Using authentication
 *
 * By using the `auth` key you can use authentication. The type sub option
 * can be used to specify which authentication strategy you want to use.
 * CakePHP comes with a few built-in strategies:
 *
 * - Basic
 * - Digest
 * - Oauth
 *
 * ### Using proxies
 *
 * By using the `proxy` key you can set authentication credentials for
 * a proxy if you need to use one. The type sub option can be used to
 * specify which authentication strategy you want to use.
 * CakePHP comes with built-in support for basic authentication.
 */
class Client
{
    use InstanceConfigTrait;

    /**
     * Default configuration for the client.
     *
     * @var array
     */
    protected $_defaultConfig = [
        'adapter' => null,
        'host' => null,
        'port' => null,
        'scheme' => 'http',
        'timeout' => 30,
        'ssl_verify_peer' => true,
        'ssl_verify_peer_name' => true,
        'ssl_verify_depth' => 5,
        'ssl_verify_host' => true,
        'redirect' => false,
        'protocolVersion' => '1.1',
    ];

    /**
     * List of cookies from responses made with this client.
     *
     * Cookies are indexed by the cookie's domain or
     * request host name.
     *
     * @var \Cake\Http\Cookie\CookieCollection
     */
    protected $_cookies;

    /**
     * Adapter for sending requests.
     *
     * @var \Cake\Http\Client\AdapterInterface
     */
    protected $_adapter;

    /**
     * Create a new HTTP Client.
     *
     * ### Config options
     *
     * You can set the following options when creating a client:
     *
     * - host - The hostname to do requests on.
     * - port - The port to use.
     * - scheme - The default scheme/protocol to use. Defaults to http.
     * - timeout - The timeout in seconds. Defaults to 30
     * - ssl_verify_peer - Whether or not SSL certificates should be validated.
     *   Defaults to true.
     * - ssl_verify_peer_name - Whether or not peer names should be validated.
     *   Defaults to true.
     * - ssl_verify_depth - The maximum certificate chain depth to traverse.
     *   Defaults to 5.
     * - ssl_verify_host - Verify that the certificate and hostname match.
     *   Defaults to true.
     * - redirect - Number of redirects to follow. Defaults to false.
     * - adapter - The adapter class name or instance. Defaults to
     *   \Cake\Http\Client\Adapter\Curl if `curl` extension is loaded else
     *   \Cake\Http\Client\Adapter\Stream.
     * - protocolVersion - The HTTP protocol version to use. Defaults to 1.1
     *
     * @param array $config Config options for scoped clients.
     * @throws \InvalidArgumentException
     */
    public function __construct($config = [])
    {
        $this->setConfig($config);

        $adapter = $this->_config['adapter'];
        if ($adapter === null) {
            $adapter = Curl::class;

            if (!extension_loaded('curl')) {
                $adapter = Stream::class;
            }
        } else {
            $this->setConfig('adapter', null);
        }

        if (is_string($adapter)) {
            $adapter = new $adapter();
        }

        if (!$adapter instanceof AdapterInterface) {
            throw new InvalidArgumentException('Adapter must be an instance of Cake\Http\Client\AdapterInterface');
        }
        $this->_adapter = $adapter;

        if (!empty($this->_config['cookieJar'])) {
            $this->_cookies = $this->_config['cookieJar'];
            $this->setConfig('cookieJar', null);
        } else {
            $this->_cookies = new CookieCollection();
        }
    }

    /**
     * Get the cookies stored in the Client.
     *
     * @return \Cake\Http\Cookie\CookieCollection
     */
    public function cookies()
    {
        return $this->_cookies;
    }

    /**
     * Adds a cookie to the Client collection.
     *
     * @param \Cake\Http\Cookie\CookieInterface $cookie Cookie object.
     * @return $this
     * @throws \InvalidArgumentException
     */
    public function addCookie(CookieInterface $cookie)
    {
        if (!$cookie->getDomain() || !$cookie->getPath()) {
            throw new InvalidArgumentException('Cookie must have a domain and a path set.');
        }
        $this->_cookies = $this->_cookies->add($cookie);

        return $this;
    }

    /**
     * Do a GET request.
     *
     * The $data argument supports a special `_content` key
     * for providing a request body in a GET request. This is
     * generally not used, but services like ElasticSearch use
     * this feature.
     *
     * @param string $url The url or path you want to request.
     * @param array $data The query data you want to send.
     * @param array $options Additional options for the request.
     * @return \Cake\Http\Client\Response
     */
    public function get($url, $data = [], array $options = [])
    {
        $options = $this->_mergeOptions($options);
        $body = null;
        if (isset($data['_content'])) {
            $body = $data['_content'];
            unset($data['_content']);
        }
        $url = $this->buildUrl($url, $data, $options);

        return $this->_doRequest(
            Request::METHOD_GET,
            $url,
            $body,
            $options
        );
    }

    /**
     * Do a POST request.
     *
     * @param string $url The url or path you want to request.
     * @param mixed $data The post data you want to send.
     * @param array $options Additional options for the request.
     * @return \Cake\Http\Client\Response
     */
    public function post($url, $data = [], array $options = [])
    {
        $options = $this->_mergeOptions($options);
        $url = $this->buildUrl($url, [], $options);

        return $this->_doRequest(Request::METHOD_POST, $url, $data, $options);
    }

    /**
     * Do a PUT request.
     *
     * @param string $url The url or path you want to request.
     * @param mixed $data The request data you want to send.
     * @param array $options Additional options for the request.
     * @return \Cake\Http\Client\Response
     */
    public function put($url, $data = [], array $options = [])
    {
        $options = $this->_mergeOptions($options);
        $url = $this->buildUrl($url, [], $options);

        return $this->_doRequest(Request::METHOD_PUT, $url, $data, $options);
    }

    /**
     * Do a PATCH request.
     *
     * @param string $url The url or path you want to request.
     * @param mixed $data The request data you want to send.
     * @param array $options Additional options for the request.
     * @return \Cake\Http\Client\Response
     */
    public function patch($url, $data = [], array $options = [])
    {
        $options = $this->_mergeOptions($options);
        $url = $this->buildUrl($url, [], $options);

        return $this->_doRequest(Request::METHOD_PATCH, $url, $data, $options);
    }

    /**
     * Do an OPTIONS request.
     *
     * @param string $url The url or path you want to request.
     * @param mixed $data The request data you want to send.
     * @param array $options Additional options for the request.
     * @return \Cake\Http\Client\Response
     */
    public function options($url, $data = [], array $options = [])
    {
        $options = $this->_mergeOptions($options);
        $url = $this->buildUrl($url, [], $options);

        return $this->_doRequest(Request::METHOD_OPTIONS, $url, $data, $options);
    }

    /**
     * Do a TRACE request.
     *
     * @param string $url The url or path you want to request.
     * @param mixed $data The request data you want to send.
     * @param array $options Additional options for the request.
     * @return \Cake\Http\Client\Response
     */
    public function trace($url, $data = [], array $options = [])
    {
        $options = $this->_mergeOptions($options);
        $url = $this->buildUrl($url, [], $options);

        return $this->_doRequest(Request::METHOD_TRACE, $url, $data, $options);
    }

    /**
     * Do a DELETE request.
     *
     * @param string $url The url or path you want to request.
     * @param mixed $data The request data you want to send.
     * @param array $options Additional options for the request.
     * @return \Cake\Http\Client\Response
     */
    public function delete($url, $data = [], array $options = [])
    {
        $options = $this->_mergeOptions($options);
        $url = $this->buildUrl($url, [], $options);

        return $this->_doRequest(Request::METHOD_DELETE, $url, $data, $options);
    }

    /**
     * Do a HEAD request.
     *
     * @param string $url The url or path you want to request.
     * @param array $data The query string data you want to send.
     * @param array $options Additional options for the request.
     * @return \Cake\Http\Client\Response
     */
    public function head($url, array $data = [], array $options = [])
    {
        $options = $this->_mergeOptions($options);
        $url = $this->buildUrl($url, $data, $options);

        return $this->_doRequest(Request::METHOD_HEAD, $url, '', $options);
    }

    /**
     * Helper method for doing non-GET requests.
     *
     * @param string $method HTTP method.
     * @param string $url URL to request.
     * @param mixed $data The request body.
     * @param array $options The options to use. Contains auth, proxy, etc.
     * @return \Cake\Http\Client\Response
     */
    protected function _doRequest($method, $url, $data, $options)
    {
        $request = $this->_createRequest(
            $method,
            $url,
            $data,
            $options
        );

        return $this->send($request, $options);
    }

    /**
     * Does a recursive merge of the parameter with the scope config.
     *
     * @param array $options Options to merge.
     * @return array Options merged with set config.
     */
    protected function _mergeOptions($options)
    {
        return Hash::merge($this->_config, $options);
    }

    /**
     * Send a request.
     *
     * Used internally by other methods, but can also be used to send
     * handcrafted Request objects.
     *
     * @param \Cake\Http\Client\Request $request The request to send.
     * @param array $options Additional options to use.
     * @return \Cake\Http\Client\Response
     */
    public function send(Request $request, $options = [])
    {
        $redirects = 0;
        if (isset($options['redirect'])) {
            $redirects = (int)$options['redirect'];
            unset($options['redirect']);
        }

        do {
            $response = $this->_sendRequest($request, $options);

            $handleRedirect = $response->isRedirect() && $redirects-- > 0;
            if ($handleRedirect) {
                $url = $request->getUri();

                $location = $response->getHeaderLine('Location');
                $locationUrl = $this->buildUrl($location, [], [
                    'host' => $url->getHost(),
                    'port' => $url->getPort(),
                    'scheme' => $url->getScheme(),
                    'protocolRelative' => true,
                ]);
                $request = $request->withUri(new Uri($locationUrl));
                $request = $this->_cookies->addToRequest($request, []);
            }
        } while ($handleRedirect);

        return $response;
    }

    /**
     * Send a request without redirection.
     *
     * @param \Cake\Http\Client\Request $request The request to send.
     * @param array $options Additional options to use.
     * @return \Cake\Http\Client\Response
     */
    protected function _sendRequest(Request $request, $options)
    {
        $responses = $this->_adapter->send($request, $options);
        $url = $request->getUri();
        foreach ($responses as $response) {
            $this->_cookies = $this->_cookies->addFromResponse($response, $request);
        }

        return array_pop($responses);
    }

    /**
     * Generate a URL based on the scoped client options.
     *
     * @param string $url Either a full URL or just the path.
     * @param string|array $query The query data for the URL.
     * @param array $options The config options stored with Client::config()
     * @return string A complete url with scheme, port, host, and path.
     */
    public function buildUrl($url, $query = [], $options = [])
    {
        if (empty($options) && empty($query)) {
            return $url;
        }
        if ($query) {
            $q = (strpos($url, '?') === false) ? '?' : '&';
            $url .= $q;
            $url .= is_string($query) ? $query : http_build_query($query);
        }
        $defaults = [
            'host' => null,
            'port' => null,
            'scheme' => 'http',
            'protocolRelative' => false,
        ];
        $options += $defaults;

        if ($options['protocolRelative'] && preg_match('#^//#', $url)) {
            $url = $options['scheme'] . ':' . $url;
        }
        if (preg_match('#^https?://#', $url)) {
            return $url;
        }

        $defaultPorts = [
            'http' => 80,
            'https' => 443,
        ];
        $out = $options['scheme'] . '://' . $options['host'];
        if ($options['port'] && $options['port'] != $defaultPorts[$options['scheme']]) {
            $out .= ':' . $options['port'];
        }
        $out .= '/' . ltrim($url, '/');

        return $out;
    }

    /**
     * Creates a new request object based on the parameters.
     *
     * @param string $method HTTP method name.
     * @param string $url The url including query string.
     * @param mixed $data The request body.
     * @param array $options The options to use. Contains auth, proxy, etc.
     * @return \Cake\Http\Client\Request
     */
    protected function _createRequest($method, $url, $data, $options)
    {
        $headers = isset($options['headers']) ? (array)$options['headers'] : [];
        if (isset($options['type'])) {
            $headers = array_merge($headers, $this->_typeHeaders($options['type']));
        }
        if (is_string($data) && !isset($headers['Content-Type']) && !isset($headers['content-type'])) {
            $headers['Content-Type'] = 'application/x-www-form-urlencoded';
        }

        $request = new Request($url, $method, $headers, $data);
        $request = $request->withProtocolVersion($this->getConfig('protocolVersion'));

        $cookies = isset($options['cookies']) ? $options['cookies'] : [];
        /** @var \Cake\Http\Client\Request $request */
        $request = $this->_cookies->addToRequest($request, $cookies);
        if (isset($options['auth'])) {
            $request = $this->_addAuthentication($request, $options);
        }
        if (isset($options['proxy'])) {
            $request = $this->_addProxy($request, $options);
        }

        return $request;
    }

    /**
     * Returns headers for Accept/Content-Type based on a short type
     * or full mime-type.
     *
     * @param string $type short type alias or full mimetype.
     * @return string[] Headers to set on the request.
     * @throws \Cake\Core\Exception\Exception When an unknown type alias is used.
     */
    protected function _typeHeaders($type)
    {
        if (strpos($type, '/') !== false) {
            return [
                'Accept' => $type,
                'Content-Type' => $type,
            ];
        }
        $typeMap = [
            'json' => 'application/json',
            'xml' => 'application/xml',
        ];
        if (!isset($typeMap[$type])) {
            throw new Exception("Unknown type alias '$type'.");
        }

        return [
            'Accept' => $typeMap[$type],
            'Content-Type' => $typeMap[$type],
        ];
    }

    /**
     * Add authentication headers to the request.
     *
     * Uses the authentication type to choose the correct strategy
     * and use its methods to add headers.
     *
     * @param \Cake\Http\Client\Request $request The request to modify.
     * @param array $options Array of options containing the 'auth' key.
     * @return \Cake\Http\Client\Request The updated request object.
     */
    protected function _addAuthentication(Request $request, $options)
    {
        $auth = $options['auth'];
        /** @var \Cake\Http\Client\Auth\Basic $adapter */
        $adapter = $this->_createAuth($auth, $options);
        $result = $adapter->authentication($request, $options['auth']);

        return $result ?: $request;
    }

    /**
     * Add proxy authentication headers.
     *
     * Uses the authentication type to choose the correct strategy
     * and use its methods to add headers.
     *
     * @param \Cake\Http\Client\Request $request The request to modify.
     * @param array $options Array of options containing the 'proxy' key.
     * @return \Cake\Http\Client\Request The updated request object.
     */
    protected function _addProxy(Request $request, $options)
    {
        $auth = $options['proxy'];
        /** @var \Cake\Http\Client\Auth\Basic $adapter */
        $adapter = $this->_createAuth($auth, $options);
        $result = $adapter->proxyAuthentication($request, $options['proxy']);

        return $result ?: $request;
    }

    /**
     * Create the authentication strategy.
     *
     * Use the configuration options to create the correct
     * authentication strategy handler.
     *
     * @param array $auth The authentication options to use.
     * @param array $options The overall request options to use.
     * @return object Authentication strategy instance.
     * @throws \Cake\Core\Exception\Exception when an invalid strategy is chosen.
     */
    protected function _createAuth($auth, $options)
    {
        if (empty($auth['type'])) {
            $auth['type'] = 'basic';
        }
        $name = ucfirst($auth['type']);
        $class = App::className($name, 'Http/Client/Auth');
        if (!$class) {
            throw new Exception(
                sprintf('Invalid authentication type %s', $name)
            );
        }

        return new $class($this, $options);
    }
}
// @deprecated 3.4.0 Backwards compatibility with earler 3.x versions.
class_alias('Cake\Http\Client', 'Cake\Network\Http\Client');
