vendor/symfony/http-client/HttpClientTrait.php line 419

  1. <?php
  2. /*
  3.  * This file is part of the Symfony package.
  4.  *
  5.  * (c) Fabien Potencier <fabien@symfony.com>
  6.  *
  7.  * For the full copyright and license information, please view the LICENSE
  8.  * file that was distributed with this source code.
  9.  */
  10. namespace Symfony\Component\HttpClient;
  11. use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
  12. use Symfony\Component\HttpClient\Exception\TransportException;
  13. /**
  14.  * Provides the common logic from writing HttpClientInterface implementations.
  15.  *
  16.  * All private methods are static to prevent implementers from creating memory leaks via circular references.
  17.  *
  18.  * @author Nicolas Grekas <p@tchwork.com>
  19.  */
  20. trait HttpClientTrait
  21. {
  22.     private static int $CHUNK_SIZE 16372;
  23.     public function withOptions(array $options): static
  24.     {
  25.         $clone = clone $this;
  26.         $clone->defaultOptions self::mergeDefaultOptions($options$this->defaultOptions);
  27.         return $clone;
  28.     }
  29.     /**
  30.      * Validates and normalizes method, URL and options, and merges them with defaults.
  31.      *
  32.      * @throws InvalidArgumentException When a not-supported option is found
  33.      */
  34.     private static function prepareRequest(?string $method, ?string $url, array $options, array $defaultOptions = [], bool $allowExtraOptions false): array
  35.     {
  36.         if (null !== $method) {
  37.             if (\strlen($method) !== strspn($method'ABCDEFGHIJKLMNOPQRSTUVWXYZ')) {
  38.                 throw new InvalidArgumentException(sprintf('Invalid HTTP method "%s", only uppercase letters are accepted.'$method));
  39.             }
  40.             if (!$method) {
  41.                 throw new InvalidArgumentException('The HTTP method cannot be empty.');
  42.             }
  43.         }
  44.         $options self::mergeDefaultOptions($options$defaultOptions$allowExtraOptions);
  45.         $buffer $options['buffer'] ?? true;
  46.         if ($buffer instanceof \Closure) {
  47.             $options['buffer'] = static function (array $headers) use ($buffer) {
  48.                 if (!\is_bool($buffer $buffer($headers))) {
  49.                     if (!\is_array($bufferInfo = @stream_get_meta_data($buffer))) {
  50.                         throw new \LogicException(sprintf('The closure passed as option "buffer" must return bool or stream resource, got "%s".'get_debug_type($buffer)));
  51.                     }
  52.                     if (false === strpbrk($bufferInfo['mode'], 'acew+')) {
  53.                         throw new \LogicException(sprintf('The stream returned by the closure passed as option "buffer" must be writeable, got mode "%s".'$bufferInfo['mode']));
  54.                     }
  55.                 }
  56.                 return $buffer;
  57.             };
  58.         } elseif (!\is_bool($buffer)) {
  59.             if (!\is_array($bufferInfo = @stream_get_meta_data($buffer))) {
  60.                 throw new InvalidArgumentException(sprintf('Option "buffer" must be bool, stream resource or Closure, "%s" given.'get_debug_type($buffer)));
  61.             }
  62.             if (false === strpbrk($bufferInfo['mode'], 'acew+')) {
  63.                 throw new InvalidArgumentException(sprintf('The stream in option "buffer" must be writeable, mode "%s" given.'$bufferInfo['mode']));
  64.             }
  65.         }
  66.         if (isset($options['json'])) {
  67.             if (isset($options['body']) && '' !== $options['body']) {
  68.                 throw new InvalidArgumentException('Define either the "json" or the "body" option, setting both is not supported.');
  69.             }
  70.             $options['body'] = self::jsonEncode($options['json']);
  71.             unset($options['json']);
  72.             if (!isset($options['normalized_headers']['content-type'])) {
  73.                 $options['normalized_headers']['content-type'] = ['Content-Type: application/json'];
  74.             }
  75.         }
  76.         if (!isset($options['normalized_headers']['accept'])) {
  77.             $options['normalized_headers']['accept'] = ['Accept: */*'];
  78.         }
  79.         if (isset($options['body'])) {
  80.             if (\is_array($options['body']) && (!isset($options['normalized_headers']['content-type'][0]) || !str_contains($options['normalized_headers']['content-type'][0], 'application/x-www-form-urlencoded'))) {
  81.                 $options['normalized_headers']['content-type'] = ['Content-Type: application/x-www-form-urlencoded'];
  82.             }
  83.             $options['body'] = self::normalizeBody($options['body']);
  84.             if (\is_string($options['body'])
  85.                 && (string) \strlen($options['body']) !== substr($h $options['normalized_headers']['content-length'][0] ?? ''16)
  86.                 && ('' !== $h || '' !== $options['body'])
  87.             ) {
  88.                 if ('chunked' === substr($options['normalized_headers']['transfer-encoding'][0] ?? ''\strlen('Transfer-Encoding: '))) {
  89.                     unset($options['normalized_headers']['transfer-encoding']);
  90.                     $options['body'] = self::dechunk($options['body']);
  91.                 }
  92.                 $options['normalized_headers']['content-length'] = [substr_replace($h ?: 'Content-Length: '\strlen($options['body']), 16)];
  93.             }
  94.         }
  95.         if (isset($options['peer_fingerprint'])) {
  96.             $options['peer_fingerprint'] = self::normalizePeerFingerprint($options['peer_fingerprint']);
  97.         }
  98.         // Validate on_progress
  99.         if (isset($options['on_progress']) && !\is_callable($onProgress $options['on_progress'])) {
  100.             throw new InvalidArgumentException(sprintf('Option "on_progress" must be callable, "%s" given.'get_debug_type($onProgress)));
  101.         }
  102.         if (\is_array($options['auth_basic'] ?? null)) {
  103.             $count \count($options['auth_basic']);
  104.             if ($count <= || $count 2) {
  105.                 throw new InvalidArgumentException(sprintf('Option "auth_basic" must contain 1 or 2 elements, "%s" given.'$count));
  106.             }
  107.             $options['auth_basic'] = implode(':'$options['auth_basic']);
  108.         }
  109.         if (!\is_string($options['auth_basic'] ?? '')) {
  110.             throw new InvalidArgumentException(sprintf('Option "auth_basic" must be string or an array, "%s" given.'get_debug_type($options['auth_basic'])));
  111.         }
  112.         if (isset($options['auth_bearer'])) {
  113.             if (!\is_string($options['auth_bearer'])) {
  114.                 throw new InvalidArgumentException(sprintf('Option "auth_bearer" must be a string, "%s" given.'get_debug_type($options['auth_bearer'])));
  115.             }
  116.             if (preg_match('{[^\x21-\x7E]}'$options['auth_bearer'])) {
  117.                 throw new InvalidArgumentException('Invalid character found in option "auth_bearer": '.json_encode($options['auth_bearer']).'.');
  118.             }
  119.         }
  120.         if (isset($options['auth_basic'], $options['auth_bearer'])) {
  121.             throw new InvalidArgumentException('Define either the "auth_basic" or the "auth_bearer" option, setting both is not supported.');
  122.         }
  123.         if (null !== $url) {
  124.             // Merge auth with headers
  125.             if (($options['auth_basic'] ?? false) && !($options['normalized_headers']['authorization'] ?? false)) {
  126.                 $options['normalized_headers']['authorization'] = ['Authorization: Basic '.base64_encode($options['auth_basic'])];
  127.             }
  128.             // Merge bearer with headers
  129.             if (($options['auth_bearer'] ?? false) && !($options['normalized_headers']['authorization'] ?? false)) {
  130.                 $options['normalized_headers']['authorization'] = ['Authorization: Bearer '.$options['auth_bearer']];
  131.             }
  132.             unset($options['auth_basic'], $options['auth_bearer']);
  133.             // Parse base URI
  134.             if (\is_string($options['base_uri'])) {
  135.                 $options['base_uri'] = self::parseUrl($options['base_uri']);
  136.             }
  137.             // Validate and resolve URL
  138.             $url self::parseUrl($url$options['query']);
  139.             $url self::resolveUrl($url$options['base_uri'], $defaultOptions['query'] ?? []);
  140.         }
  141.         // Finalize normalization of options
  142.         $options['http_version'] = (string) ($options['http_version'] ?? '') ?: null;
  143.         if ($options['timeout'] = (float) ($options['timeout'] ?? \ini_get('default_socket_timeout'))) {
  144.             $options['timeout'] = 172800.0// 2 days
  145.         }
  146.         $options['max_duration'] = isset($options['max_duration']) ? (float) $options['max_duration'] : 0;
  147.         $options['headers'] = array_merge(...array_values($options['normalized_headers']));
  148.         return [$url$options];
  149.     }
  150.     /**
  151.      * @throws InvalidArgumentException When an invalid option is found
  152.      */
  153.     private static function mergeDefaultOptions(array $options, array $defaultOptionsbool $allowExtraOptions false): array
  154.     {
  155.         $options['normalized_headers'] = self::normalizeHeaders($options['headers'] ?? []);
  156.         if ($defaultOptions['headers'] ?? false) {
  157.             $options['normalized_headers'] += self::normalizeHeaders($defaultOptions['headers']);
  158.         }
  159.         $options['headers'] = array_merge(...array_values($options['normalized_headers']) ?: [[]]);
  160.         if ($resolve $options['resolve'] ?? false) {
  161.             $options['resolve'] = [];
  162.             foreach ($resolve as $k => $v) {
  163.                 $options['resolve'][substr(self::parseUrl('http://'.$k)['authority'], 2)] = (string) $v;
  164.             }
  165.         }
  166.         // Option "query" is never inherited from defaults
  167.         $options['query'] ??= [];
  168.         $options += $defaultOptions;
  169.         if (isset(self::$emptyDefaults)) {
  170.             foreach (self::$emptyDefaults as $k => $v) {
  171.                 if (!isset($options[$k])) {
  172.                     $options[$k] = $v;
  173.                 }
  174.             }
  175.         }
  176.         if (isset($defaultOptions['extra'])) {
  177.             $options['extra'] += $defaultOptions['extra'];
  178.         }
  179.         if ($resolve $defaultOptions['resolve'] ?? false) {
  180.             foreach ($resolve as $k => $v) {
  181.                 $options['resolve'] += [substr(self::parseUrl('http://'.$k)['authority'], 2) => (string) $v];
  182.             }
  183.         }
  184.         if ($allowExtraOptions || !$defaultOptions) {
  185.             return $options;
  186.         }
  187.         // Look for unsupported options
  188.         foreach ($options as $name => $v) {
  189.             if (\array_key_exists($name$defaultOptions) || 'normalized_headers' === $name) {
  190.                 continue;
  191.             }
  192.             if ('auth_ntlm' === $name) {
  193.                 if (!\extension_loaded('curl')) {
  194.                     $msg 'try installing the "curl" extension to use "%s" instead.';
  195.                 } else {
  196.                     $msg 'try using "%s" instead.';
  197.                 }
  198.                 throw new InvalidArgumentException(sprintf('Option "auth_ntlm" is not supported by "%s", '.$msg__CLASS__CurlHttpClient::class));
  199.             }
  200.             $alternatives = [];
  201.             foreach ($defaultOptions as $k => $v) {
  202.                 if (levenshtein($name$k) <= \strlen($name) / || str_contains($k$name)) {
  203.                     $alternatives[] = $k;
  204.                 }
  205.             }
  206.             throw new InvalidArgumentException(sprintf('Unsupported option "%s" passed to "%s", did you mean "%s"?'$name__CLASS__implode('", "'$alternatives ?: array_keys($defaultOptions))));
  207.         }
  208.         return $options;
  209.     }
  210.     /**
  211.      * @return string[][]
  212.      *
  213.      * @throws InvalidArgumentException When an invalid header is found
  214.      */
  215.     private static function normalizeHeaders(array $headers): array
  216.     {
  217.         $normalizedHeaders = [];
  218.         foreach ($headers as $name => $values) {
  219.             if ($values instanceof \Stringable) {
  220.                 $values = (string) $values;
  221.             }
  222.             if (\is_int($name)) {
  223.                 if (!\is_string($values)) {
  224.                     throw new InvalidArgumentException(sprintf('Invalid value for header "%s": expected string, "%s" given.'$nameget_debug_type($values)));
  225.                 }
  226.                 [$name$values] = explode(':'$values2);
  227.                 $values = [ltrim($values)];
  228.             } elseif (!is_iterable($values)) {
  229.                 if (\is_object($values)) {
  230.                     throw new InvalidArgumentException(sprintf('Invalid value for header "%s": expected string, "%s" given.'$nameget_debug_type($values)));
  231.                 }
  232.                 $values = (array) $values;
  233.             }
  234.             $lcName strtolower($name);
  235.             $normalizedHeaders[$lcName] = [];
  236.             foreach ($values as $value) {
  237.                 $normalizedHeaders[$lcName][] = $value $name.': '.$value;
  238.                 if (\strlen($value) !== strcspn($value"\r\n\0")) {
  239.                     throw new InvalidArgumentException(sprintf('Invalid header: CR/LF/NUL found in "%s".'$value));
  240.                 }
  241.             }
  242.         }
  243.         return $normalizedHeaders;
  244.     }
  245.     /**
  246.      * @param array|string|resource|\Traversable|\Closure $body
  247.      *
  248.      * @return string|resource|\Closure
  249.      *
  250.      * @throws InvalidArgumentException When an invalid body is passed
  251.      */
  252.     private static function normalizeBody($body)
  253.     {
  254.         if (\is_array($body)) {
  255.             array_walk_recursive($body$caster = static function (&$v) use (&$caster) {
  256.                 if (\is_object($v)) {
  257.                     if ($vars get_object_vars($v)) {
  258.                         array_walk_recursive($vars$caster);
  259.                         $v $vars;
  260.                     } elseif (method_exists($v'__toString')) {
  261.                         $v = (string) $v;
  262.                     }
  263.                 }
  264.             });
  265.             return http_build_query($body'''&');
  266.         }
  267.         if (\is_string($body)) {
  268.             return $body;
  269.         }
  270.         $generatorToCallable = static function (\Generator $body): \Closure {
  271.             return static function () use ($body) {
  272.                 while ($body->valid()) {
  273.                     $chunk $body->current();
  274.                     $body->next();
  275.                     if ('' !== $chunk) {
  276.                         return $chunk;
  277.                     }
  278.                 }
  279.                 return '';
  280.             };
  281.         };
  282.         if ($body instanceof \Generator) {
  283.             return $generatorToCallable($body);
  284.         }
  285.         if ($body instanceof \Traversable) {
  286.             return $generatorToCallable((static function ($body) { yield from $body; })($body));
  287.         }
  288.         if ($body instanceof \Closure) {
  289.             $r = new \ReflectionFunction($body);
  290.             $body $r->getClosure();
  291.             if ($r->isGenerator()) {
  292.                 $body $body(self::$CHUNK_SIZE);
  293.                 return $generatorToCallable($body);
  294.             }
  295.             return $body;
  296.         }
  297.         if (!\is_array(@stream_get_meta_data($body))) {
  298.             throw new InvalidArgumentException(sprintf('Option "body" must be string, stream resource, iterable or callable, "%s" given.'get_debug_type($body)));
  299.         }
  300.         return $body;
  301.     }
  302.     private static function dechunk(string $body): string
  303.     {
  304.         $h fopen('php://temp''w+');
  305.         stream_filter_append($h'dechunk'\STREAM_FILTER_WRITE);
  306.         fwrite($h$body);
  307.         $body stream_get_contents($h, -10);
  308.         rewind($h);
  309.         ftruncate($h0);
  310.         if (fwrite($h'-') && '' !== stream_get_contents($h, -10)) {
  311.             throw new TransportException('Request body has broken chunked encoding.');
  312.         }
  313.         return $body;
  314.     }
  315.     /**
  316.      * @throws InvalidArgumentException When an invalid fingerprint is passed
  317.      */
  318.     private static function normalizePeerFingerprint(mixed $fingerprint): array
  319.     {
  320.         if (\is_string($fingerprint)) {
  321.             $fingerprint = match (\strlen($fingerprint str_replace(':'''$fingerprint))) {
  322.                 32 => ['md5' => $fingerprint],
  323.                 40 => ['sha1' => $fingerprint],
  324.                 44 => ['pin-sha256' => [$fingerprint]],
  325.                 64 => ['sha256' => $fingerprint],
  326.                 default => throw new InvalidArgumentException(sprintf('Cannot auto-detect fingerprint algorithm for "%s".'$fingerprint)),
  327.             };
  328.         } elseif (\is_array($fingerprint)) {
  329.             foreach ($fingerprint as $algo => $hash) {
  330.                 $fingerprint[$algo] = 'pin-sha256' === $algo ? (array) $hash str_replace(':'''$hash);
  331.             }
  332.         } else {
  333.             throw new InvalidArgumentException(sprintf('Option "peer_fingerprint" must be string or array, "%s" given.'get_debug_type($fingerprint)));
  334.         }
  335.         return $fingerprint;
  336.     }
  337.     /**
  338.      * @throws InvalidArgumentException When the value cannot be json-encoded
  339.      */
  340.     private static function jsonEncode(mixed $valueint $flags nullint $maxDepth 512): string
  341.     {
  342.         $flags ??= \JSON_HEX_TAG \JSON_HEX_APOS \JSON_HEX_AMP \JSON_HEX_QUOT \JSON_PRESERVE_ZERO_FRACTION;
  343.         try {
  344.             $value json_encode($value$flags \JSON_THROW_ON_ERROR$maxDepth);
  345.         } catch (\JsonException $e) {
  346.             throw new InvalidArgumentException('Invalid value for "json" option: '.$e->getMessage());
  347.         }
  348.         return $value;
  349.     }
  350.     /**
  351.      * Resolves a URL against a base URI.
  352.      *
  353.      * @see https://tools.ietf.org/html/rfc3986#section-5.2.2
  354.      *
  355.      * @throws InvalidArgumentException When an invalid URL is passed
  356.      */
  357.     private static function resolveUrl(array $url, ?array $base, array $queryDefaults = []): array
  358.     {
  359.         if (null !== $base && '' === ($base['scheme'] ?? '').($base['authority'] ?? '')) {
  360.             throw new InvalidArgumentException(sprintf('Invalid "base_uri" option: host or scheme is missing in "%s".'implode(''$base)));
  361.         }
  362.         if (null === $url['scheme'] && (null === $base || null === $base['scheme'])) {
  363.             throw new InvalidArgumentException(sprintf('Invalid URL: scheme is missing in "%s". Did you forget to add "http(s)://"?'implode(''$base ?? $url)));
  364.         }
  365.         if (null === $base && '' === $url['scheme'].$url['authority']) {
  366.             throw new InvalidArgumentException(sprintf('Invalid URL: no "base_uri" option was provided and host or scheme is missing in "%s".'implode(''$url)));
  367.         }
  368.         if (null !== $url['scheme']) {
  369.             $url['path'] = self::removeDotSegments($url['path'] ?? '');
  370.         } else {
  371.             if (null !== $url['authority']) {
  372.                 $url['path'] = self::removeDotSegments($url['path'] ?? '');
  373.             } else {
  374.                 if (null === $url['path']) {
  375.                     $url['path'] = $base['path'];
  376.                     $url['query'] ??= $base['query'];
  377.                 } else {
  378.                     if ('/' !== $url['path'][0]) {
  379.                         if (null === $base['path']) {
  380.                             $url['path'] = '/'.$url['path'];
  381.                         } else {
  382.                             $segments explode('/'$base['path']);
  383.                             array_splice($segments, -11, [$url['path']]);
  384.                             $url['path'] = implode('/'$segments);
  385.                         }
  386.                     }
  387.                     $url['path'] = self::removeDotSegments($url['path']);
  388.                 }
  389.                 $url['authority'] = $base['authority'];
  390.                 if ($queryDefaults) {
  391.                     $url['query'] = '?'.self::mergeQueryString(substr($url['query'] ?? ''1), $queryDefaultsfalse);
  392.                 }
  393.             }
  394.             $url['scheme'] = $base['scheme'];
  395.         }
  396.         if ('' === ($url['path'] ?? '')) {
  397.             $url['path'] = '/';
  398.         }
  399.         if ('?' === ($url['query'] ?? '')) {
  400.             $url['query'] = null;
  401.         }
  402.         return $url;
  403.     }
  404.     /**
  405.      * Parses a URL and fixes its encoding if needed.
  406.      *
  407.      * @throws InvalidArgumentException When an invalid URL is passed
  408.      */
  409.     private static function parseUrl(string $url, array $query = [], array $allowedSchemes = ['http' => 80'https' => 443]): array
  410.     {
  411.         if (false === $parts parse_url($url)) {
  412.             throw new InvalidArgumentException(sprintf('Malformed URL "%s".'$url));
  413.         }
  414.         if ($query) {
  415.             $parts['query'] = self::mergeQueryString($parts['query'] ?? null$querytrue);
  416.         }
  417.         $port $parts['port'] ?? 0;
  418.         if (null !== $scheme $parts['scheme'] ?? null) {
  419.             if (!isset($allowedSchemes[$scheme strtolower($scheme)])) {
  420.                 throw new InvalidArgumentException(sprintf('Unsupported scheme in "%s".'$url));
  421.             }
  422.             $port $allowedSchemes[$scheme] === $port $port;
  423.             $scheme .= ':';
  424.         }
  425.         if (null !== $host $parts['host'] ?? null) {
  426.             if (!\defined('INTL_IDNA_VARIANT_UTS46') && preg_match('/[\x80-\xFF]/'$host)) {
  427.                 throw new InvalidArgumentException(sprintf('Unsupported IDN "%s", try enabling the "intl" PHP extension or running "composer require symfony/polyfill-intl-idn".'$host));
  428.             }
  429.             $host \defined('INTL_IDNA_VARIANT_UTS46') ? idn_to_ascii($host\IDNA_DEFAULT \IDNA_USE_STD3_RULES \IDNA_CHECK_BIDI \IDNA_CHECK_CONTEXTJ \IDNA_NONTRANSITIONAL_TO_ASCII\INTL_IDNA_VARIANT_UTS46) ?: strtolower($host) : strtolower($host);
  430.             $host .= $port ':'.$port '';
  431.         }
  432.         foreach (['user''pass''path''query''fragment'] as $part) {
  433.             if (!isset($parts[$part])) {
  434.                 continue;
  435.             }
  436.             if (str_contains($parts[$part], '%')) {
  437.                 // https://tools.ietf.org/html/rfc3986#section-2.3
  438.                 $parts[$part] = preg_replace_callback('/%(?:2[DE]|3[0-9]|[46][1-9A-F]|5F|[57][0-9A]|7E)++/i', function ($m) { return rawurldecode($m[0]); }, $parts[$part]);
  439.             }
  440.             // https://tools.ietf.org/html/rfc3986#section-3.3
  441.             $parts[$part] = preg_replace_callback("#[^-A-Za-z0-9._~!$&/'()[\]*+,;=:@\\\\^`{|}%]++#", function ($m) { return rawurlencode($m[0]); }, $parts[$part]);
  442.         }
  443.         return [
  444.             'scheme' => $scheme,
  445.             'authority' => null !== $host '//'.(isset($parts['user']) ? $parts['user'].(isset($parts['pass']) ? ':'.$parts['pass'] : '').'@' '').$host null,
  446.             'path' => isset($parts['path'][0]) ? $parts['path'] : null,
  447.             'query' => isset($parts['query']) ? '?'.$parts['query'] : null,
  448.             'fragment' => isset($parts['fragment']) ? '#'.$parts['fragment'] : null,
  449.         ];
  450.     }
  451.     /**
  452.      * Removes dot-segments from a path.
  453.      *
  454.      * @see https://tools.ietf.org/html/rfc3986#section-5.2.4
  455.      */
  456.     private static function removeDotSegments(string $path)
  457.     {
  458.         $result '';
  459.         while (!\in_array($path, ['''.''..'], true)) {
  460.             if ('.' === $path[0] && (str_starts_with($path$p '../') || str_starts_with($path$p './'))) {
  461.                 $path substr($path\strlen($p));
  462.             } elseif ('/.' === $path || str_starts_with($path'/./')) {
  463.                 $path substr_replace($path'/'03);
  464.             } elseif ('/..' === $path || str_starts_with($path'/../')) {
  465.                 $i strrpos($result'/');
  466.                 $result $i substr($result0$i) : '';
  467.                 $path substr_replace($path'/'04);
  468.             } else {
  469.                 $i strpos($path'/'1) ?: \strlen($path);
  470.                 $result .= substr($path0$i);
  471.                 $path substr($path$i);
  472.             }
  473.         }
  474.         return $result;
  475.     }
  476.     /**
  477.      * Merges and encodes a query array with a query string.
  478.      *
  479.      * @throws InvalidArgumentException When an invalid query-string value is passed
  480.      */
  481.     private static function mergeQueryString(?string $queryString, array $queryArraybool $replace): ?string
  482.     {
  483.         if (!$queryArray) {
  484.             return $queryString;
  485.         }
  486.         $query = [];
  487.         if (null !== $queryString) {
  488.             foreach (explode('&'$queryString) as $v) {
  489.                 if ('' !== $v) {
  490.                     $k urldecode(explode('='$v2)[0]);
  491.                     $query[$k] = (isset($query[$k]) ? $query[$k].'&' '').$v;
  492.                 }
  493.             }
  494.         }
  495.         if ($replace) {
  496.             foreach ($queryArray as $k => $v) {
  497.                 if (null === $v) {
  498.                     unset($query[$k]);
  499.                 }
  500.             }
  501.         }
  502.         $queryString http_build_query($queryArray'''&'\PHP_QUERY_RFC3986);
  503.         $queryArray = [];
  504.         if ($queryString) {
  505.             if (str_contains($queryString'%')) {
  506.                 // https://tools.ietf.org/html/rfc3986#section-2.3 + some chars not encoded by browsers
  507.                 $queryString strtr($queryString, [
  508.                     '%21' => '!',
  509.                     '%24' => '$',
  510.                     '%28' => '(',
  511.                     '%29' => ')',
  512.                     '%2A' => '*',
  513.                     '%2F' => '/',
  514.                     '%3A' => ':',
  515.                     '%3B' => ';',
  516.                     '%40' => '@',
  517.                     '%5B' => '[',
  518.                     '%5C' => '\\',
  519.                     '%5D' => ']',
  520.                     '%5E' => '^',
  521.                     '%60' => '`',
  522.                     '%7C' => '|',
  523.                 ]);
  524.             }
  525.             foreach (explode('&'$queryString) as $v) {
  526.                 $queryArray[rawurldecode(explode('='$v2)[0])] = $v;
  527.             }
  528.         }
  529.         return implode('&'$replace array_replace($query$queryArray) : ($query $queryArray));
  530.     }
  531.     /**
  532.      * Loads proxy configuration from the same environment variables as curl when no proxy is explicitly set.
  533.      */
  534.     private static function getProxy(?string $proxy, array $url, ?string $noProxy): ?array
  535.     {
  536.         if (null === $proxy self::getProxyUrl($proxy$url)) {
  537.             return null;
  538.         }
  539.         $proxy = (parse_url($proxy) ?: []) + ['scheme' => 'http'];
  540.         if (!isset($proxy['host'])) {
  541.             throw new TransportException('Invalid HTTP proxy: host is missing.');
  542.         }
  543.         if ('http' === $proxy['scheme']) {
  544.             $proxyUrl 'tcp://'.$proxy['host'].':'.($proxy['port'] ?? '80');
  545.         } elseif ('https' === $proxy['scheme']) {
  546.             $proxyUrl 'ssl://'.$proxy['host'].':'.($proxy['port'] ?? '443');
  547.         } else {
  548.             throw new TransportException(sprintf('Unsupported proxy scheme "%s": "http" or "https" expected.'$proxy['scheme']));
  549.         }
  550.         $noProxy ??= $_SERVER['no_proxy'] ?? $_SERVER['NO_PROXY'] ?? '';
  551.         $noProxy $noProxy preg_split('/[\s,]+/'$noProxy) : [];
  552.         return [
  553.             'url' => $proxyUrl,
  554.             'auth' => isset($proxy['user']) ? 'Basic '.base64_encode(rawurldecode($proxy['user']).':'.rawurldecode($proxy['pass'] ?? '')) : null,
  555.             'no_proxy' => $noProxy,
  556.         ];
  557.     }
  558.     private static function getProxyUrl(?string $proxy, array $url): ?string
  559.     {
  560.         if (null !== $proxy) {
  561.             return $proxy;
  562.         }
  563.         // Ignore HTTP_PROXY except on the CLI to work around httpoxy set of vulnerabilities
  564.         $proxy $_SERVER['http_proxy'] ?? (\in_array(\PHP_SAPI, ['cli''phpdbg'], true) ? $_SERVER['HTTP_PROXY'] ?? null null) ?? $_SERVER['all_proxy'] ?? $_SERVER['ALL_PROXY'] ?? null;
  565.         if ('https:' === $url['scheme']) {
  566.             $proxy $_SERVER['https_proxy'] ?? $_SERVER['HTTPS_PROXY'] ?? $proxy;
  567.         }
  568.         return $proxy;
  569.     }
  570.     private static function shouldBuffer(array $headers): bool
  571.     {
  572.         if (null === $contentType $headers['content-type'][0] ?? null) {
  573.             return false;
  574.         }
  575.         if (false !== $i strpos($contentType';')) {
  576.             $contentType substr($contentType0$i);
  577.         }
  578.         return $contentType && preg_match('#^(?:text/|application/(?:.+\+)?(?:json|xml)$)#i'$contentType);
  579.     }
  580. }