├── AmpHttpClient.php ├── AsyncDecoratorTrait.php ├── CHANGELOG.md ├── CachingHttpClient.php ├── Chunk ├── DataChunk.php ├── ErrorChunk.php ├── FirstChunk.php ├── InformationalChunk.php ├── LastChunk.php └── ServerSentEvent.php ├── CurlHttpClient.php ├── DataCollector └── HttpClientDataCollector.php ├── DecoratorTrait.php ├── DependencyInjection └── HttpClientPass.php ├── EventSourceHttpClient.php ├── Exception ├── ClientException.php ├── EventSourceException.php ├── HttpExceptionTrait.php ├── InvalidArgumentException.php ├── JsonException.php ├── RedirectionException.php ├── ServerException.php ├── TimeoutException.php └── TransportException.php ├── HttpClient.php ├── HttpClientTrait.php ├── HttpOptions.php ├── HttplugClient.php ├── Internal ├── AmpBodyV4.php ├── AmpBodyV5.php ├── AmpClientStateV4.php ├── AmpClientStateV5.php ├── AmpListenerV4.php ├── AmpListenerV5.php ├── AmpResolverV4.php ├── AmpResolverV5.php ├── Canary.php ├── ClientState.php ├── CurlClientState.php ├── DnsCache.php ├── HttplugWaitLoop.php ├── NativeClientState.php └── PushedResponse.php ├── LICENSE ├── Messenger ├── PingWebhookMessage.php └── PingWebhookMessageHandler.php ├── MockHttpClient.php ├── NativeHttpClient.php ├── NoPrivateNetworkHttpClient.php ├── Psr18Client.php ├── README.md ├── Response ├── AmpResponseV4.php ├── AmpResponseV5.php ├── AsyncContext.php ├── AsyncResponse.php ├── CommonResponseTrait.php ├── CurlResponse.php ├── HttplugPromise.php ├── JsonMockResponse.php ├── MockResponse.php ├── NativeResponse.php ├── ResponseStream.php ├── StreamWrapper.php ├── StreamableInterface.php ├── TraceableResponse.php └── TransportResponseTrait.php ├── Retry ├── GenericRetryStrategy.php └── RetryStrategyInterface.php ├── RetryableHttpClient.php ├── ScopingHttpClient.php ├── Test └── HarFileResponseFactory.php ├── ThrottlingHttpClient.php ├── TraceableHttpClient.php ├── UriTemplateHttpClient.php └── composer.json /AmpHttpClient.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient; 13 | 14 | use Amp\CancelledException; 15 | use Amp\DeferredFuture; 16 | use Amp\Http\Client\DelegateHttpClient; 17 | use Amp\Http\Client\InterceptedHttpClient; 18 | use Amp\Http\Client\PooledHttpClient; 19 | use Amp\Http\Client\Request; 20 | use Amp\Http\HttpMessage; 21 | use Amp\Http\Tunnel\Http1TunnelConnector; 22 | use Psr\Log\LoggerAwareInterface; 23 | use Psr\Log\LoggerAwareTrait; 24 | use Symfony\Component\HttpClient\Exception\TransportException; 25 | use Symfony\Component\HttpClient\Internal\AmpClientStateV4; 26 | use Symfony\Component\HttpClient\Internal\AmpClientStateV5; 27 | use Symfony\Component\HttpClient\Response\AmpResponseV4; 28 | use Symfony\Component\HttpClient\Response\AmpResponseV5; 29 | use Symfony\Component\HttpClient\Response\ResponseStream; 30 | use Symfony\Contracts\HttpClient\HttpClientInterface; 31 | use Symfony\Contracts\HttpClient\ResponseInterface; 32 | use Symfony\Contracts\HttpClient\ResponseStreamInterface; 33 | use Symfony\Contracts\Service\ResetInterface; 34 | 35 | if (!interface_exists(DelegateHttpClient::class)) { 36 | throw new \LogicException('You cannot use "Symfony\Component\HttpClient\AmpHttpClient" as the "amphp/http-client" package is not installed. Try running "composer require amphp/http-client:^4.2.1".'); 37 | } 38 | 39 | if (\PHP_VERSION_ID < 80400 && is_subclass_of(Request::class, HttpMessage::class)) { 40 | throw new \LogicException('Using "Symfony\Component\HttpClient\AmpHttpClient" with amphp/http-client >= 5 requires PHP >= 8.4. Try running "composer require amphp/http-client:^4.2.1" or upgrade to PHP >= 8.4.'); 41 | } 42 | 43 | /** 44 | * A portable implementation of the HttpClientInterface contracts based on Amp's HTTP client. 45 | * 46 | * @author Nicolas Grekas 47 | */ 48 | final class AmpHttpClient implements HttpClientInterface, LoggerAwareInterface, ResetInterface 49 | { 50 | use HttpClientTrait; 51 | use LoggerAwareTrait; 52 | 53 | public const OPTIONS_DEFAULTS = HttpClientInterface::OPTIONS_DEFAULTS + [ 54 | 'crypto_method' => \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT, 55 | ]; 56 | 57 | private array $defaultOptions = self::OPTIONS_DEFAULTS; 58 | private static array $emptyDefaults = self::OPTIONS_DEFAULTS; 59 | private AmpClientStateV4|AmpClientStateV5 $multi; 60 | 61 | /** 62 | * @param array $defaultOptions Default requests' options 63 | * @param callable|null $clientConfigurator A callable that builds a {@see DelegateHttpClient} from a {@see PooledHttpClient}; 64 | * passing null builds an {@see InterceptedHttpClient} with 2 retries on failures 65 | * @param int $maxHostConnections The maximum number of connections to a single host 66 | * @param int $maxPendingPushes The maximum number of pushed responses to accept in the queue 67 | * 68 | * @see HttpClientInterface::OPTIONS_DEFAULTS for available options 69 | */ 70 | public function __construct(array $defaultOptions = [], ?callable $clientConfigurator = null, int $maxHostConnections = 6, int $maxPendingPushes = 50) 71 | { 72 | $this->defaultOptions['buffer'] ??= self::shouldBuffer(...); 73 | 74 | if ($defaultOptions) { 75 | [, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions); 76 | } 77 | 78 | if (is_subclass_of(Request::class, HttpMessage::class)) { 79 | $this->multi = new AmpClientStateV5($clientConfigurator, $maxHostConnections, $maxPendingPushes, $this->logger); 80 | } else { 81 | $this->multi = new AmpClientStateV4($clientConfigurator, $maxHostConnections, $maxPendingPushes, $this->logger); 82 | } 83 | } 84 | 85 | /** 86 | * @see HttpClientInterface::OPTIONS_DEFAULTS for available options 87 | */ 88 | public function request(string $method, string $url, array $options = []): ResponseInterface 89 | { 90 | [$url, $options] = self::prepareRequest($method, $url, $options, $this->defaultOptions); 91 | 92 | $options['proxy'] = self::getProxy($options['proxy'], $url, $options['no_proxy']); 93 | 94 | if (null !== $options['proxy'] && !class_exists(Http1TunnelConnector::class)) { 95 | throw new \LogicException('You cannot use the "proxy" option as the "amphp/http-tunnel" package is not installed. Try running "composer require amphp/http-tunnel".'); 96 | } 97 | 98 | if ($options['bindto']) { 99 | if (str_starts_with($options['bindto'], 'if!')) { 100 | throw new TransportException(__CLASS__.' cannot bind to network interfaces, use e.g. CurlHttpClient instead.'); 101 | } 102 | if (str_starts_with($options['bindto'], 'host!')) { 103 | $options['bindto'] = substr($options['bindto'], 5); 104 | } 105 | } 106 | 107 | if (('' !== $options['body'] || 'POST' === $method || isset($options['normalized_headers']['content-length'])) && !isset($options['normalized_headers']['content-type'])) { 108 | $options['headers'][] = 'Content-Type: application/x-www-form-urlencoded'; 109 | } 110 | 111 | if (!isset($options['normalized_headers']['user-agent'])) { 112 | $options['headers'][] = 'User-Agent: Symfony HttpClient (Amp)'; 113 | } 114 | 115 | if (0 < $options['max_duration']) { 116 | $options['timeout'] = min($options['max_duration'], $options['timeout']); 117 | } 118 | 119 | if ($options['resolve']) { 120 | $this->multi->dnsCache = $options['resolve'] + $this->multi->dnsCache; 121 | } 122 | 123 | if ($options['peer_fingerprint'] && !isset($options['peer_fingerprint']['pin-sha256'])) { 124 | throw new TransportException(__CLASS__.' supports only "pin-sha256" fingerprints.'); 125 | } 126 | 127 | $request = new Request(implode('', $url), $method); 128 | $request->setBodySizeLimit(0); 129 | 130 | if ($options['http_version']) { 131 | $request->setProtocolVersions(match ((float) $options['http_version']) { 132 | 1.0 => ['1.0'], 133 | 1.1 => ['1.1', '1.0'], 134 | default => ['2', '1.1', '1.0'], 135 | }); 136 | } 137 | 138 | foreach ($options['headers'] as $v) { 139 | $h = explode(': ', $v, 2); 140 | $request->addHeader($h[0], $h[1]); 141 | } 142 | 143 | $coef = $request instanceof HttpMessage ? 1 : 1000; 144 | $request->setTcpConnectTimeout($coef * $options['timeout']); 145 | $request->setTlsHandshakeTimeout($coef * $options['timeout']); 146 | $request->setTransferTimeout($coef * $options['max_duration']); 147 | if (method_exists($request, 'setInactivityTimeout')) { 148 | $request->setInactivityTimeout(0); 149 | } 150 | 151 | if ('' !== $request->getUri()->getUserInfo() && !$request->hasHeader('authorization')) { 152 | $auth = explode(':', $request->getUri()->getUserInfo(), 2); 153 | $auth = array_map('rawurldecode', $auth) + [1 => '']; 154 | $request->setHeader('Authorization', 'Basic '.base64_encode(implode(':', $auth))); 155 | } 156 | 157 | if ($request instanceof HttpMessage) { 158 | return new AmpResponseV5($this->multi, $request, $options, $this->logger); 159 | } 160 | 161 | return new AmpResponseV4($this->multi, $request, $options, $this->logger); 162 | } 163 | 164 | public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface 165 | { 166 | if ($responses instanceof AmpResponseV4 || $responses instanceof AmpResponseV5) { 167 | $responses = [$responses]; 168 | } 169 | 170 | if ($this->multi instanceof AmpClientStateV5) { 171 | return new ResponseStream(AmpResponseV5::stream($responses, $timeout)); 172 | } 173 | 174 | return new ResponseStream(AmpResponseV4::stream($responses, $timeout)); 175 | } 176 | 177 | public function reset(): void 178 | { 179 | $this->multi->dnsCache = []; 180 | 181 | foreach ($this->multi->pushedResponses as $pushedResponses) { 182 | foreach ($pushedResponses as [$pushedUrl, $pushDeferred]) { 183 | if ($pushDeferred instanceof DeferredFuture) { 184 | $pushDeferred->error(new CancelledException()); 185 | } else { 186 | $pushDeferred->fail(new CancelledException()); 187 | } 188 | 189 | $this->logger?->debug(\sprintf('Unused pushed response: "%s"', $pushedUrl)); 190 | } 191 | } 192 | 193 | $this->multi->pushedResponses = []; 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /AsyncDecoratorTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient; 13 | 14 | use Symfony\Component\HttpClient\Response\AsyncResponse; 15 | use Symfony\Component\HttpClient\Response\ResponseStream; 16 | use Symfony\Contracts\HttpClient\ResponseInterface; 17 | use Symfony\Contracts\HttpClient\ResponseStreamInterface; 18 | 19 | /** 20 | * Eases with processing responses while streaming them. 21 | * 22 | * @author Nicolas Grekas 23 | */ 24 | trait AsyncDecoratorTrait 25 | { 26 | use DecoratorTrait; 27 | 28 | /** 29 | * @return AsyncResponse 30 | */ 31 | abstract public function request(string $method, string $url, array $options = []): ResponseInterface; 32 | 33 | public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface 34 | { 35 | if ($responses instanceof AsyncResponse) { 36 | $responses = [$responses]; 37 | } 38 | 39 | return new ResponseStream(AsyncResponse::stream($responses, $timeout, static::class)); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 7.3 5 | --- 6 | 7 | * Add IPv6 support to `NativeHttpClient` 8 | * Allow using HTTP/3 with the `CurlHttpClient` 9 | 10 | 7.2 11 | --- 12 | 13 | * Add support for amphp/http-client v5 on PHP 8.4+ 14 | 15 | 7.1 16 | --- 17 | 18 | * Add `HttpOptions::setHeader()` to add or replace a single header 19 | * Allow mocking `start_time` info in `MockResponse` 20 | * Add `MockResponse::fromFile()` and `JsonMockResponse::fromFile()` methods to help using fixtures files 21 | * Add `ThrottlingHttpClient` to enable limiting the number of requests within a certain period 22 | * Deprecate the `setLogger()` methods of the `NoPrivateNetworkHttpClient`, `TraceableHttpClient` and `ScopingHttpClient` classes, configure the logger of the wrapped clients directly instead 23 | 24 | 7.0 25 | --- 26 | 27 | * Remove implementing `Http\Message\RequestFactory` from `HttplugClient` 28 | 29 | 6.4 30 | --- 31 | 32 | * Add `HarFileResponseFactory` testing utility, allow to replay responses from `.har` files 33 | * Add `max_retries` option to `RetryableHttpClient` to adjust the retry logic on a per request level 34 | * Add `PingWehookMessage` and `PingWebhookMessageHandler` 35 | * Enable using EventSourceHttpClient::connect() for both GET and POST 36 | 37 | 6.3 38 | --- 39 | 40 | * Add option `crypto_method` to set the minimum TLS version and make it default to v1.2 41 | * Add `UriTemplateHttpClient` to use URI templates as specified in the RFC 6570 42 | * Add `ServerSentEvent::getArrayData()` to get the Server-Sent Event's data decoded as an array when it's a JSON payload 43 | * Allow array of urls as `base_uri` option value in `RetryableHttpClient` to retry on a new url each time 44 | * Add `JsonMockResponse`, a `MockResponse` shortcut that automatically encodes the passed body to JSON and sets the content type to `application/json` by default 45 | * Support file uploads by nesting resource streams in option "body" 46 | 47 | 6.2 48 | --- 49 | 50 | * Make `HttplugClient` implement `Psr\Http\Message\RequestFactoryInterface`, `StreamFactoryInterface` and `UriFactoryInterface` 51 | * Deprecate implementing `Http\Message\RequestFactory`, `StreamFactory` and `UriFactory` on `HttplugClient` 52 | * Add `withOptions()` to `HttplugClient` and `Psr18Client` 53 | 54 | 6.1 55 | --- 56 | 57 | * Allow yielding `Exception` from MockResponse's `$body` to mock transport errors 58 | * Remove credentials from requests redirected to same host but different port 59 | 60 | 5.4 61 | --- 62 | 63 | * Add `MockHttpClient::setResponseFactory()` method to be able to set response factory after client creating 64 | 65 | 5.3 66 | --- 67 | 68 | * Implement `HttpClientInterface::withOptions()` from `symfony/contracts` v2.4 69 | * Add `DecoratorTrait` to ease writing simple decorators 70 | 71 | 5.2.0 72 | ----- 73 | 74 | * added `AsyncDecoratorTrait` to ease processing responses without breaking async 75 | * added support for pausing responses with a new `pause_handler` callable exposed as an info item 76 | * added `StreamableInterface` to ease turning responses into PHP streams 77 | * added `MockResponse::getRequestMethod()` and `getRequestUrl()` to allow inspecting which request has been sent 78 | * added `EventSourceHttpClient` a Server-Sent events stream implementing the [EventSource specification](https://www.w3.org/TR/eventsource/#eventsource) 79 | * added option "extra.curl" to allow setting additional curl options in `CurlHttpClient` 80 | * added `RetryableHttpClient` to automatically retry failed HTTP requests. 81 | * added `extra.trace_content` option to `TraceableHttpClient` to prevent it from keeping the content in memory 82 | 83 | 5.1.0 84 | ----- 85 | 86 | * added `NoPrivateNetworkHttpClient` decorator 87 | * added `AmpHttpClient`, a portable HTTP/2 implementation based on Amp 88 | * added `LoggerAwareInterface` to `ScopingHttpClient` and `TraceableHttpClient` 89 | * made `HttpClient::create()` return an `AmpHttpClient` when `amphp/http-client` is found but curl is not or too old 90 | 91 | 4.4.0 92 | ----- 93 | 94 | * added `canceled` to `ResponseInterface::getInfo()` 95 | * added `HttpClient::createForBaseUri()` 96 | * added `HttplugClient` with support for sync and async requests 97 | * added `max_duration` option 98 | * added support for NTLM authentication 99 | * added `StreamWrapper` to cast any `ResponseInterface` instances to PHP streams. 100 | * added `$response->toStream()` to cast responses to regular PHP streams 101 | * made `Psr18Client` implement relevant PSR-17 factories and have streaming responses 102 | * added `TraceableHttpClient`, `HttpClientDataCollector` and `HttpClientPass` to integrate with the web profiler 103 | * allow enabling buffering conditionally with a Closure 104 | * allow option "buffer" to be a stream resource 105 | * allow arbitrary values for the "json" option 106 | 107 | 4.3.0 108 | ----- 109 | 110 | * added the component 111 | -------------------------------------------------------------------------------- /CachingHttpClient.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient; 13 | 14 | use Symfony\Component\HttpClient\Response\MockResponse; 15 | use Symfony\Component\HttpClient\Response\ResponseStream; 16 | use Symfony\Component\HttpFoundation\Request; 17 | use Symfony\Component\HttpKernel\HttpCache\HttpCache; 18 | use Symfony\Component\HttpKernel\HttpCache\StoreInterface; 19 | use Symfony\Component\HttpKernel\HttpClientKernel; 20 | use Symfony\Contracts\HttpClient\HttpClientInterface; 21 | use Symfony\Contracts\HttpClient\ResponseInterface; 22 | use Symfony\Contracts\HttpClient\ResponseStreamInterface; 23 | use Symfony\Contracts\Service\ResetInterface; 24 | 25 | /** 26 | * Adds caching on top of an HTTP client. 27 | * 28 | * The implementation buffers responses in memory and doesn't stream directly from the network. 29 | * You can disable/enable this layer by setting option "no_cache" under "extra" to true/false. 30 | * By default, caching is enabled unless the "buffer" option is set to false. 31 | * 32 | * @author Nicolas Grekas 33 | */ 34 | class CachingHttpClient implements HttpClientInterface, ResetInterface 35 | { 36 | use HttpClientTrait; 37 | 38 | private HttpCache $cache; 39 | private array $defaultOptions = self::OPTIONS_DEFAULTS; 40 | 41 | public function __construct( 42 | private HttpClientInterface $client, 43 | StoreInterface $store, 44 | array $defaultOptions = [], 45 | ) { 46 | if (!class_exists(HttpClientKernel::class)) { 47 | throw new \LogicException(\sprintf('Using "%s" requires the HttpKernel component, try running "composer require symfony/http-kernel".', __CLASS__)); 48 | } 49 | 50 | $kernel = new HttpClientKernel($client); 51 | $this->cache = new HttpCache($kernel, $store, null, $defaultOptions); 52 | 53 | unset($defaultOptions['debug']); 54 | unset($defaultOptions['default_ttl']); 55 | unset($defaultOptions['private_headers']); 56 | unset($defaultOptions['skip_response_headers']); 57 | unset($defaultOptions['allow_reload']); 58 | unset($defaultOptions['allow_revalidate']); 59 | unset($defaultOptions['stale_while_revalidate']); 60 | unset($defaultOptions['stale_if_error']); 61 | unset($defaultOptions['trace_level']); 62 | unset($defaultOptions['trace_header']); 63 | 64 | if ($defaultOptions) { 65 | [, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions); 66 | } 67 | } 68 | 69 | public function request(string $method, string $url, array $options = []): ResponseInterface 70 | { 71 | [$url, $options] = $this->prepareRequest($method, $url, $options, $this->defaultOptions, true); 72 | $url = implode('', $url); 73 | 74 | if (!empty($options['body']) || !empty($options['extra']['no_cache']) || !\in_array($method, ['GET', 'HEAD', 'OPTIONS'])) { 75 | return $this->client->request($method, $url, $options); 76 | } 77 | 78 | $request = Request::create($url, $method); 79 | $request->attributes->set('http_client_options', $options); 80 | 81 | foreach ($options['normalized_headers'] as $name => $values) { 82 | if ('cookie' !== $name) { 83 | foreach ($values as $value) { 84 | $request->headers->set($name, substr($value, 2 + \strlen($name)), false); 85 | } 86 | 87 | continue; 88 | } 89 | 90 | foreach ($values as $cookies) { 91 | foreach (explode('; ', substr($cookies, \strlen('Cookie: '))) as $cookie) { 92 | if ('' !== $cookie) { 93 | $cookie = explode('=', $cookie, 2); 94 | $request->cookies->set($cookie[0], $cookie[1] ?? ''); 95 | } 96 | } 97 | } 98 | } 99 | 100 | $response = $this->cache->handle($request); 101 | $response = new MockResponse($response->getContent(), [ 102 | 'http_code' => $response->getStatusCode(), 103 | 'response_headers' => $response->headers->allPreserveCase(), 104 | ]); 105 | 106 | return MockResponse::fromRequest($method, $url, $options, $response); 107 | } 108 | 109 | public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface 110 | { 111 | if ($responses instanceof ResponseInterface) { 112 | $responses = [$responses]; 113 | } 114 | 115 | $mockResponses = []; 116 | $clientResponses = []; 117 | 118 | foreach ($responses as $response) { 119 | if ($response instanceof MockResponse) { 120 | $mockResponses[] = $response; 121 | } else { 122 | $clientResponses[] = $response; 123 | } 124 | } 125 | 126 | if (!$mockResponses) { 127 | return $this->client->stream($clientResponses, $timeout); 128 | } 129 | 130 | if (!$clientResponses) { 131 | return new ResponseStream(MockResponse::stream($mockResponses, $timeout)); 132 | } 133 | 134 | return new ResponseStream((function () use ($mockResponses, $clientResponses, $timeout) { 135 | yield from MockResponse::stream($mockResponses, $timeout); 136 | yield $this->client->stream($clientResponses, $timeout); 137 | })()); 138 | } 139 | 140 | public function reset(): void 141 | { 142 | if ($this->client instanceof ResetInterface) { 143 | $this->client->reset(); 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /Chunk/DataChunk.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Chunk; 13 | 14 | use Symfony\Contracts\HttpClient\ChunkInterface; 15 | 16 | /** 17 | * @author Nicolas Grekas 18 | * 19 | * @internal 20 | */ 21 | class DataChunk implements ChunkInterface 22 | { 23 | public function __construct( 24 | private int $offset = 0, 25 | private string $content = '', 26 | ) { 27 | } 28 | 29 | public function isTimeout(): bool 30 | { 31 | return false; 32 | } 33 | 34 | public function isFirst(): bool 35 | { 36 | return false; 37 | } 38 | 39 | public function isLast(): bool 40 | { 41 | return false; 42 | } 43 | 44 | public function getInformationalStatus(): ?array 45 | { 46 | return null; 47 | } 48 | 49 | public function getContent(): string 50 | { 51 | return $this->content; 52 | } 53 | 54 | public function getOffset(): int 55 | { 56 | return $this->offset; 57 | } 58 | 59 | public function getError(): ?string 60 | { 61 | return null; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Chunk/ErrorChunk.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Chunk; 13 | 14 | use Symfony\Component\HttpClient\Exception\TimeoutException; 15 | use Symfony\Component\HttpClient\Exception\TransportException; 16 | use Symfony\Contracts\HttpClient\ChunkInterface; 17 | 18 | /** 19 | * @author Nicolas Grekas 20 | * 21 | * @internal 22 | */ 23 | class ErrorChunk implements ChunkInterface 24 | { 25 | private bool $didThrow = false; 26 | private string $errorMessage; 27 | private ?\Throwable $error = null; 28 | 29 | public function __construct( 30 | private int $offset, 31 | \Throwable|string $error, 32 | ) { 33 | if (\is_string($error)) { 34 | $this->errorMessage = $error; 35 | } else { 36 | $this->error = $error; 37 | $this->errorMessage = $error->getMessage(); 38 | } 39 | } 40 | 41 | public function isTimeout(): bool 42 | { 43 | $this->didThrow = true; 44 | 45 | if (null !== $this->error) { 46 | throw new TransportException($this->errorMessage, 0, $this->error); 47 | } 48 | 49 | return true; 50 | } 51 | 52 | public function isFirst(): bool 53 | { 54 | $this->didThrow = true; 55 | throw null !== $this->error ? new TransportException($this->errorMessage, 0, $this->error) : new TimeoutException($this->errorMessage); 56 | } 57 | 58 | public function isLast(): bool 59 | { 60 | $this->didThrow = true; 61 | throw null !== $this->error ? new TransportException($this->errorMessage, 0, $this->error) : new TimeoutException($this->errorMessage); 62 | } 63 | 64 | public function getInformationalStatus(): ?array 65 | { 66 | $this->didThrow = true; 67 | throw null !== $this->error ? new TransportException($this->errorMessage, 0, $this->error) : new TimeoutException($this->errorMessage); 68 | } 69 | 70 | public function getContent(): string 71 | { 72 | $this->didThrow = true; 73 | throw null !== $this->error ? new TransportException($this->errorMessage, 0, $this->error) : new TimeoutException($this->errorMessage); 74 | } 75 | 76 | public function getOffset(): int 77 | { 78 | return $this->offset; 79 | } 80 | 81 | public function getError(): ?string 82 | { 83 | return $this->errorMessage; 84 | } 85 | 86 | public function didThrow(?bool $didThrow = null): bool 87 | { 88 | if (null !== $didThrow && $this->didThrow !== $didThrow) { 89 | return !$this->didThrow = $didThrow; 90 | } 91 | 92 | return $this->didThrow; 93 | } 94 | 95 | public function __sleep(): array 96 | { 97 | throw new \BadMethodCallException('Cannot serialize '.__CLASS__); 98 | } 99 | 100 | public function __wakeup(): void 101 | { 102 | throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); 103 | } 104 | 105 | public function __destruct() 106 | { 107 | if (!$this->didThrow) { 108 | $this->didThrow = true; 109 | throw null !== $this->error ? new TransportException($this->errorMessage, 0, $this->error) : new TimeoutException($this->errorMessage); 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Chunk/FirstChunk.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Chunk; 13 | 14 | /** 15 | * @author Nicolas Grekas 16 | * 17 | * @internal 18 | */ 19 | class FirstChunk extends DataChunk 20 | { 21 | public function isFirst(): bool 22 | { 23 | return true; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Chunk/InformationalChunk.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Chunk; 13 | 14 | /** 15 | * @author Nicolas Grekas 16 | * 17 | * @internal 18 | */ 19 | class InformationalChunk extends DataChunk 20 | { 21 | private array $status; 22 | 23 | public function __construct(int $statusCode, array $headers) 24 | { 25 | $this->status = [$statusCode, $headers]; 26 | 27 | parent::__construct(); 28 | } 29 | 30 | public function getInformationalStatus(): ?array 31 | { 32 | return $this->status; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Chunk/LastChunk.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Chunk; 13 | 14 | /** 15 | * @author Nicolas Grekas 16 | * 17 | * @internal 18 | */ 19 | class LastChunk extends DataChunk 20 | { 21 | public function isLast(): bool 22 | { 23 | return true; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Chunk/ServerSentEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Chunk; 13 | 14 | use Symfony\Component\HttpClient\Exception\JsonException; 15 | use Symfony\Contracts\HttpClient\ChunkInterface; 16 | 17 | /** 18 | * @author Antoine Bluchet 19 | * @author Nicolas Grekas 20 | */ 21 | final class ServerSentEvent extends DataChunk implements ChunkInterface 22 | { 23 | private string $data = ''; 24 | private string $id = ''; 25 | private string $type = 'message'; 26 | private float $retry = 0; 27 | private ?array $jsonData = null; 28 | 29 | public function __construct(string $content) 30 | { 31 | parent::__construct(-1, $content); 32 | 33 | // remove BOM 34 | if (str_starts_with($content, "\xEF\xBB\xBF")) { 35 | $content = substr($content, 3); 36 | } 37 | 38 | foreach (preg_split("/(?:\r\n|[\r\n])/", $content) as $line) { 39 | if (0 === $i = strpos($line, ':')) { 40 | continue; 41 | } 42 | 43 | $i = false === $i ? \strlen($line) : $i; 44 | $field = substr($line, 0, $i); 45 | $i += 1 + (' ' === ($line[1 + $i] ?? '')); 46 | 47 | switch ($field) { 48 | case 'id': 49 | $this->id = substr($line, $i); 50 | break; 51 | case 'event': 52 | $this->type = substr($line, $i); 53 | break; 54 | case 'data': 55 | $this->data .= ('' === $this->data ? '' : "\n").substr($line, $i); 56 | break; 57 | case 'retry': 58 | $retry = substr($line, $i); 59 | 60 | if ('' !== $retry && \strlen($retry) === strspn($retry, '0123456789')) { 61 | $this->retry = $retry / 1000.0; 62 | } 63 | 64 | break; 65 | } 66 | } 67 | } 68 | 69 | public function getId(): string 70 | { 71 | return $this->id; 72 | } 73 | 74 | public function getType(): string 75 | { 76 | return $this->type; 77 | } 78 | 79 | public function getData(): string 80 | { 81 | return $this->data; 82 | } 83 | 84 | public function getRetry(): float 85 | { 86 | return $this->retry; 87 | } 88 | 89 | /** 90 | * Gets the SSE data decoded as an array when it's a JSON payload. 91 | */ 92 | public function getArrayData(): array 93 | { 94 | if (null !== $this->jsonData) { 95 | return $this->jsonData; 96 | } 97 | 98 | if ('' === $this->data) { 99 | throw new JsonException(\sprintf('Server-Sent Event%s data is empty.', '' !== $this->id ? \sprintf(' "%s"', $this->id) : '')); 100 | } 101 | 102 | try { 103 | $jsonData = json_decode($this->data, true, 512, \JSON_BIGINT_AS_STRING | \JSON_THROW_ON_ERROR); 104 | } catch (\JsonException $e) { 105 | throw new JsonException(\sprintf('Decoding Server-Sent Event%s failed: ', '' !== $this->id ? \sprintf(' "%s"', $this->id) : '').$e->getMessage(), $e->getCode()); 106 | } 107 | 108 | if (!\is_array($jsonData)) { 109 | throw new JsonException(\sprintf('JSON content was expected to decode to an array, "%s" returned in Server-Sent Event%s.', get_debug_type($jsonData), '' !== $this->id ? \sprintf(' "%s"', $this->id) : '')); 110 | } 111 | 112 | return $this->jsonData = $jsonData; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /DataCollector/HttpClientDataCollector.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\DataCollector; 13 | 14 | use Symfony\Component\HttpClient\Exception\TransportException; 15 | use Symfony\Component\HttpClient\HttpClientTrait; 16 | use Symfony\Component\HttpClient\TraceableHttpClient; 17 | use Symfony\Component\HttpFoundation\Request; 18 | use Symfony\Component\HttpFoundation\Response; 19 | use Symfony\Component\HttpKernel\DataCollector\DataCollector; 20 | use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface; 21 | use Symfony\Component\Process\Process; 22 | use Symfony\Component\VarDumper\Caster\ImgStub; 23 | 24 | /** 25 | * @author Jérémy Romey 26 | */ 27 | final class HttpClientDataCollector extends DataCollector implements LateDataCollectorInterface 28 | { 29 | use HttpClientTrait; 30 | 31 | /** 32 | * @var TraceableHttpClient[] 33 | */ 34 | private array $clients = []; 35 | 36 | public function registerClient(string $name, TraceableHttpClient $client): void 37 | { 38 | $this->clients[$name] = $client; 39 | } 40 | 41 | public function collect(Request $request, Response $response, ?\Throwable $exception = null): void 42 | { 43 | $this->lateCollect(); 44 | } 45 | 46 | public function lateCollect(): void 47 | { 48 | $this->data['request_count'] = $this->data['request_count'] ?? 0; 49 | $this->data['error_count'] = $this->data['error_count'] ?? 0; 50 | $this->data += ['clients' => []]; 51 | 52 | foreach ($this->clients as $name => $client) { 53 | [$errorCount, $traces] = $this->collectOnClient($client); 54 | 55 | $this->data['clients'] += [ 56 | $name => [ 57 | 'traces' => [], 58 | 'error_count' => 0, 59 | ], 60 | ]; 61 | 62 | $this->data['clients'][$name]['traces'] = array_merge($this->data['clients'][$name]['traces'], $traces); 63 | $this->data['request_count'] += \count($traces); 64 | $this->data['error_count'] += $errorCount; 65 | $this->data['clients'][$name]['error_count'] += $errorCount; 66 | 67 | $client->reset(); 68 | } 69 | } 70 | 71 | public function getClients(): array 72 | { 73 | return $this->data['clients'] ?? []; 74 | } 75 | 76 | public function getRequestCount(): int 77 | { 78 | return $this->data['request_count'] ?? 0; 79 | } 80 | 81 | public function getErrorCount(): int 82 | { 83 | return $this->data['error_count'] ?? 0; 84 | } 85 | 86 | public function getName(): string 87 | { 88 | return 'http_client'; 89 | } 90 | 91 | public function reset(): void 92 | { 93 | $this->data = [ 94 | 'clients' => [], 95 | 'request_count' => 0, 96 | 'error_count' => 0, 97 | ]; 98 | } 99 | 100 | private function collectOnClient(TraceableHttpClient $client): array 101 | { 102 | $traces = $client->getTracedRequests(); 103 | $errorCount = 0; 104 | $baseInfo = [ 105 | 'response_headers' => 1, 106 | 'retry_count' => 1, 107 | 'redirect_count' => 1, 108 | 'redirect_url' => 1, 109 | 'user_data' => 1, 110 | 'error' => 1, 111 | 'url' => 1, 112 | ]; 113 | 114 | foreach ($traces as $i => $trace) { 115 | if (400 <= ($trace['info']['http_code'] ?? 0)) { 116 | ++$errorCount; 117 | } 118 | 119 | $info = $trace['info']; 120 | $traces[$i]['http_code'] = $info['http_code'] ?? 0; 121 | 122 | unset($info['filetime'], $info['http_code'], $info['ssl_verify_result'], $info['content_type']); 123 | 124 | if (($info['http_method'] ?? null) === $trace['method']) { 125 | unset($info['http_method']); 126 | } 127 | 128 | if (($info['url'] ?? null) === $trace['url']) { 129 | unset($info['url']); 130 | } 131 | 132 | foreach ($info as $k => $v) { 133 | if (!$v || (is_numeric($v) && 0 > $v)) { 134 | unset($info[$k]); 135 | } 136 | } 137 | 138 | if (\is_string($content = $trace['content'])) { 139 | $contentType = 'application/octet-stream'; 140 | 141 | foreach ($info['response_headers'] ?? [] as $h) { 142 | if (0 === stripos($h, 'content-type: ')) { 143 | $contentType = substr($h, \strlen('content-type: ')); 144 | break; 145 | } 146 | } 147 | 148 | if (str_starts_with($contentType, 'image/') && class_exists(ImgStub::class)) { 149 | $content = new ImgStub($content, $contentType, ''); 150 | } else { 151 | $content = [$content]; 152 | } 153 | 154 | $content = ['response_content' => $content]; 155 | } elseif (\is_array($content)) { 156 | $content = ['response_json' => $content]; 157 | } else { 158 | $content = []; 159 | } 160 | 161 | if (isset($info['retry_count'])) { 162 | $content['retries'] = $info['previous_info']; 163 | unset($info['previous_info']); 164 | } 165 | 166 | $debugInfo = array_diff_key($info, $baseInfo); 167 | $info = ['info' => $debugInfo] + array_diff_key($info, $debugInfo) + $content; 168 | unset($traces[$i]['info']); // break PHP reference used by TraceableHttpClient 169 | $traces[$i]['info'] = $this->cloneVar($info); 170 | $traces[$i]['options'] = $this->cloneVar($trace['options']); 171 | $traces[$i]['curlCommand'] = $this->getCurlCommand($trace); 172 | } 173 | 174 | return [$errorCount, $traces]; 175 | } 176 | 177 | private function getCurlCommand(array $trace): ?string 178 | { 179 | if (!isset($trace['info']['debug'])) { 180 | return null; 181 | } 182 | 183 | $url = $trace['info']['original_url'] ?? $trace['info']['url'] ?? $trace['url']; 184 | $command = ['curl', '--compressed']; 185 | 186 | if (isset($trace['options']['resolve'])) { 187 | $port = parse_url($url, \PHP_URL_PORT) ?: (str_starts_with('http:', $url) ? 80 : 443); 188 | foreach ($trace['options']['resolve'] as $host => $ip) { 189 | if (null !== $ip) { 190 | $command[] = '--resolve '.escapeshellarg("$host:$port:$ip"); 191 | } 192 | } 193 | } 194 | 195 | $dataArg = []; 196 | 197 | if ($json = $trace['options']['json'] ?? null) { 198 | $dataArg[] = '--data-raw '.$this->escapePayload(self::jsonEncode($json)); 199 | } elseif ($body = $trace['options']['body'] ?? null) { 200 | if (\is_string($body)) { 201 | $dataArg[] = '--data-raw '.$this->escapePayload($body); 202 | } elseif (\is_array($body)) { 203 | try { 204 | $body = explode('&', self::normalizeBody($body)); 205 | } catch (TransportException) { 206 | return null; 207 | } 208 | foreach ($body as $value) { 209 | $dataArg[] = '--data-raw '.$this->escapePayload(urldecode($value)); 210 | } 211 | } else { 212 | return null; 213 | } 214 | } 215 | 216 | $dataArg = $dataArg ? implode(' ', $dataArg) : null; 217 | 218 | foreach (explode("\n", $trace['info']['debug']) as $line) { 219 | $line = substr($line, 0, -1); 220 | 221 | if (str_starts_with('< ', $line)) { 222 | // End of the request, beginning of the response. Stop parsing. 223 | break; 224 | } 225 | 226 | if (str_starts_with('Due to a bug in curl ', $line)) { 227 | // When the curl client disables debug info due to a curl bug, we cannot build the command. 228 | return null; 229 | } 230 | 231 | if ('' === $line || preg_match('/^[*<]|(Host: )/', $line)) { 232 | continue; 233 | } 234 | 235 | if (preg_match('/^> ([A-Z]+)/', $line, $match)) { 236 | $command[] = \sprintf('--request %s', $match[1]); 237 | $command[] = \sprintf('--url %s', escapeshellarg($url)); 238 | continue; 239 | } 240 | 241 | $command[] = '--header '.escapeshellarg($line); 242 | } 243 | 244 | if (null !== $dataArg) { 245 | $command[] = $dataArg; 246 | } 247 | 248 | return implode(" \\\n ", $command); 249 | } 250 | 251 | private function escapePayload(string $payload): string 252 | { 253 | static $useProcess; 254 | 255 | if ($useProcess ??= \function_exists('proc_open') && class_exists(Process::class)) { 256 | return substr((new Process(['', $payload]))->getCommandLine(), 3); 257 | } 258 | 259 | if ('\\' === \DIRECTORY_SEPARATOR) { 260 | return '"'.str_replace('"', '""', $payload).'"'; 261 | } 262 | 263 | return "'".str_replace("'", "'\\''", $payload)."'"; 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /DecoratorTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient; 13 | 14 | use Symfony\Contracts\HttpClient\HttpClientInterface; 15 | use Symfony\Contracts\HttpClient\ResponseInterface; 16 | use Symfony\Contracts\HttpClient\ResponseStreamInterface; 17 | use Symfony\Contracts\Service\ResetInterface; 18 | 19 | /** 20 | * Eases with writing decorators. 21 | * 22 | * @author Nicolas Grekas 23 | */ 24 | trait DecoratorTrait 25 | { 26 | private HttpClientInterface $client; 27 | 28 | public function __construct(?HttpClientInterface $client = null) 29 | { 30 | $this->client = $client ?? HttpClient::create(); 31 | } 32 | 33 | public function request(string $method, string $url, array $options = []): ResponseInterface 34 | { 35 | return $this->client->request($method, $url, $options); 36 | } 37 | 38 | public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface 39 | { 40 | return $this->client->stream($responses, $timeout); 41 | } 42 | 43 | public function withOptions(array $options): static 44 | { 45 | $clone = clone $this; 46 | $clone->client = $this->client->withOptions($options); 47 | 48 | return $clone; 49 | } 50 | 51 | public function reset(): void 52 | { 53 | if ($this->client instanceof ResetInterface) { 54 | $this->client->reset(); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /DependencyInjection/HttpClientPass.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\DependencyInjection; 13 | 14 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; 15 | use Symfony\Component\DependencyInjection\ContainerBuilder; 16 | use Symfony\Component\DependencyInjection\ContainerInterface; 17 | use Symfony\Component\DependencyInjection\Reference; 18 | use Symfony\Component\HttpClient\TraceableHttpClient; 19 | 20 | final class HttpClientPass implements CompilerPassInterface 21 | { 22 | public function process(ContainerBuilder $container): void 23 | { 24 | if (!$container->hasDefinition('data_collector.http_client')) { 25 | return; 26 | } 27 | 28 | foreach ($container->findTaggedServiceIds('http_client.client') as $id => $tags) { 29 | $container->register('.debug.'.$id, TraceableHttpClient::class) 30 | ->setArguments([new Reference('.debug.'.$id.'.inner'), new Reference('debug.stopwatch', ContainerInterface::IGNORE_ON_INVALID_REFERENCE), new Reference('profiler.is_disabled_state_checker', ContainerInterface::IGNORE_ON_INVALID_REFERENCE)]) 31 | ->addTag('kernel.reset', ['method' => 'reset']) 32 | ->setDecoratedService($id, null, 5); 33 | $container->getDefinition('data_collector.http_client') 34 | ->addMethodCall('registerClient', [$id, new Reference('.debug.'.$id)]); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /EventSourceHttpClient.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient; 13 | 14 | use Symfony\Component\HttpClient\Chunk\DataChunk; 15 | use Symfony\Component\HttpClient\Chunk\ServerSentEvent; 16 | use Symfony\Component\HttpClient\Exception\EventSourceException; 17 | use Symfony\Component\HttpClient\Response\AsyncContext; 18 | use Symfony\Component\HttpClient\Response\AsyncResponse; 19 | use Symfony\Contracts\HttpClient\ChunkInterface; 20 | use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; 21 | use Symfony\Contracts\HttpClient\HttpClientInterface; 22 | use Symfony\Contracts\HttpClient\ResponseInterface; 23 | use Symfony\Contracts\Service\ResetInterface; 24 | 25 | /** 26 | * @author Antoine Bluchet 27 | * @author Nicolas Grekas 28 | */ 29 | final class EventSourceHttpClient implements HttpClientInterface, ResetInterface 30 | { 31 | use AsyncDecoratorTrait, HttpClientTrait { 32 | AsyncDecoratorTrait::withOptions insteadof HttpClientTrait; 33 | } 34 | 35 | public function __construct( 36 | ?HttpClientInterface $client = null, 37 | private float $reconnectionTime = 10.0, 38 | ) { 39 | $this->client = $client ?? HttpClient::create(); 40 | } 41 | 42 | public function connect(string $url, array $options = [], string $method = 'GET'): ResponseInterface 43 | { 44 | return $this->request($method, $url, self::mergeDefaultOptions($options, [ 45 | 'buffer' => false, 46 | 'headers' => [ 47 | 'Accept' => 'text/event-stream', 48 | 'Cache-Control' => 'no-cache', 49 | ], 50 | ], true)); 51 | } 52 | 53 | public function request(string $method, string $url, array $options = []): ResponseInterface 54 | { 55 | $state = new class { 56 | public ?string $buffer = null; 57 | public ?string $lastEventId = null; 58 | public float $reconnectionTime; 59 | public ?float $lastError = null; 60 | }; 61 | $state->reconnectionTime = $this->reconnectionTime; 62 | 63 | if ($accept = self::normalizeHeaders($options['headers'] ?? [])['accept'] ?? []) { 64 | $state->buffer = \in_array($accept, [['Accept: text/event-stream'], ['accept: text/event-stream']], true) ? '' : null; 65 | 66 | if (null !== $state->buffer) { 67 | $options['extra']['trace_content'] = false; 68 | } 69 | } 70 | 71 | return new AsyncResponse($this->client, $method, $url, $options, static function (ChunkInterface $chunk, AsyncContext $context) use ($state, $method, $url, $options) { 72 | if (null !== $state->buffer) { 73 | $context->setInfo('reconnection_time', $state->reconnectionTime); 74 | $isTimeout = false; 75 | } 76 | $lastError = $state->lastError; 77 | $state->lastError = null; 78 | 79 | try { 80 | $isTimeout = $chunk->isTimeout(); 81 | 82 | if (null !== $chunk->getInformationalStatus() || $context->getInfo('canceled')) { 83 | yield $chunk; 84 | 85 | return; 86 | } 87 | } catch (TransportExceptionInterface) { 88 | $state->lastError = $lastError ?? hrtime(true) / 1E9; 89 | 90 | if (null === $state->buffer || ($isTimeout && hrtime(true) / 1E9 - $state->lastError < $state->reconnectionTime)) { 91 | yield $chunk; 92 | } else { 93 | $options['headers']['Last-Event-ID'] = $state->lastEventId; 94 | $state->buffer = ''; 95 | $state->lastError = hrtime(true) / 1E9; 96 | $context->getResponse()->cancel(); 97 | $context->replaceRequest($method, $url, $options); 98 | if ($isTimeout) { 99 | yield $chunk; 100 | } else { 101 | $context->pause($state->reconnectionTime); 102 | } 103 | } 104 | 105 | return; 106 | } 107 | 108 | if ($chunk->isFirst()) { 109 | if (preg_match('/^text\/event-stream(;|$)/i', $context->getHeaders()['content-type'][0] ?? '')) { 110 | $state->buffer = ''; 111 | } elseif (null !== $lastError || (null !== $state->buffer && 200 === $context->getStatusCode())) { 112 | throw new EventSourceException(\sprintf('Response content-type is "%s" while "text/event-stream" was expected for "%s".', $context->getHeaders()['content-type'][0] ?? '', $context->getInfo('url'))); 113 | } else { 114 | $context->passthru(); 115 | } 116 | 117 | if (null === $lastError) { 118 | yield $chunk; 119 | } 120 | 121 | return; 122 | } 123 | 124 | if ($chunk->isLast()) { 125 | if ('' !== $content = $state->buffer) { 126 | $state->buffer = ''; 127 | yield new DataChunk(-1, $content); 128 | } 129 | 130 | yield $chunk; 131 | 132 | return; 133 | } 134 | 135 | $content = $state->buffer.$chunk->getContent(); 136 | $events = preg_split('/((?:\r\n){2,}|\r{2,}|\n{2,})/', $content, -1, \PREG_SPLIT_DELIM_CAPTURE); 137 | $state->buffer = array_pop($events); 138 | 139 | for ($i = 0; isset($events[$i]); $i += 2) { 140 | $content = $events[$i].$events[1 + $i]; 141 | if (!preg_match('/(?:^|\r\n|[\r\n])[^:\r\n]/', $content)) { 142 | yield new DataChunk(-1, $content); 143 | 144 | continue; 145 | } 146 | 147 | $event = new ServerSentEvent($content); 148 | 149 | if ('' !== $event->getId()) { 150 | $context->setInfo('last_event_id', $state->lastEventId = $event->getId()); 151 | } 152 | 153 | if ($event->getRetry()) { 154 | $context->setInfo('reconnection_time', $state->reconnectionTime = $event->getRetry()); 155 | } 156 | 157 | yield $event; 158 | } 159 | }); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /Exception/ClientException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Exception; 13 | 14 | use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; 15 | 16 | /** 17 | * Represents a 4xx response. 18 | * 19 | * @author Nicolas Grekas 20 | */ 21 | final class ClientException extends \RuntimeException implements ClientExceptionInterface 22 | { 23 | use HttpExceptionTrait; 24 | } 25 | -------------------------------------------------------------------------------- /Exception/EventSourceException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Exception; 13 | 14 | use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; 15 | 16 | /** 17 | * @author Nicolas Grekas 18 | */ 19 | final class EventSourceException extends \RuntimeException implements DecodingExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Exception/HttpExceptionTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Exception; 13 | 14 | use Symfony\Contracts\HttpClient\ResponseInterface; 15 | 16 | /** 17 | * @author Nicolas Grekas 18 | * 19 | * @internal 20 | */ 21 | trait HttpExceptionTrait 22 | { 23 | public function __construct( 24 | private ResponseInterface $response, 25 | ) { 26 | $code = $response->getInfo('http_code'); 27 | $url = $response->getInfo('url'); 28 | $message = \sprintf('HTTP %d returned for "%s".', $code, $url); 29 | 30 | $httpCodeFound = false; 31 | $isJson = false; 32 | foreach (array_reverse($response->getInfo('response_headers')) as $h) { 33 | if (str_starts_with($h, 'HTTP/')) { 34 | if ($httpCodeFound) { 35 | break; 36 | } 37 | 38 | $message = \sprintf('%s returned for "%s".', $h, $url); 39 | $httpCodeFound = true; 40 | } 41 | 42 | if (0 === stripos($h, 'content-type:')) { 43 | if (preg_match('/\bjson\b/i', $h)) { 44 | $isJson = true; 45 | } 46 | 47 | if ($httpCodeFound) { 48 | break; 49 | } 50 | } 51 | } 52 | 53 | // Try to guess a better error message using common API error formats 54 | // The MIME type isn't explicitly checked because some formats inherit from others 55 | // Ex: JSON:API follows RFC 7807 semantics, Hydra can be used in any JSON-LD-compatible format 56 | if ($isJson && $body = json_decode($response->getContent(false), true)) { 57 | if (isset($body['hydra:title']) || isset($body['hydra:description'])) { 58 | // see http://www.hydra-cg.com/spec/latest/core/#description-of-http-status-codes-and-errors 59 | $separator = isset($body['hydra:title'], $body['hydra:description']) ? "\n\n" : ''; 60 | $message = ($body['hydra:title'] ?? '').$separator.($body['hydra:description'] ?? ''); 61 | } elseif ((isset($body['title']) || isset($body['detail'])) 62 | && (\is_scalar($body['title'] ?? '') && \is_scalar($body['detail'] ?? ''))) { 63 | // see RFC 7807 and https://jsonapi.org/format/#error-objects 64 | $separator = isset($body['title'], $body['detail']) ? "\n\n" : ''; 65 | $message = ($body['title'] ?? '').$separator.($body['detail'] ?? ''); 66 | } 67 | } 68 | 69 | parent::__construct($message, $code); 70 | } 71 | 72 | public function getResponse(): ResponseInterface 73 | { 74 | return $this->response; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Exception/InvalidArgumentException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Exception; 13 | 14 | use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; 15 | 16 | /** 17 | * @author Nicolas Grekas 18 | */ 19 | final class InvalidArgumentException extends \InvalidArgumentException implements TransportExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Exception/JsonException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Exception; 13 | 14 | use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; 15 | 16 | /** 17 | * Thrown by responses' toArray() method when their content cannot be JSON-decoded. 18 | * 19 | * @author Nicolas Grekas 20 | */ 21 | final class JsonException extends \JsonException implements DecodingExceptionInterface 22 | { 23 | } 24 | -------------------------------------------------------------------------------- /Exception/RedirectionException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Exception; 13 | 14 | use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; 15 | 16 | /** 17 | * Represents a 3xx response. 18 | * 19 | * @author Nicolas Grekas 20 | */ 21 | final class RedirectionException extends \RuntimeException implements RedirectionExceptionInterface 22 | { 23 | use HttpExceptionTrait; 24 | } 25 | -------------------------------------------------------------------------------- /Exception/ServerException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Exception; 13 | 14 | use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; 15 | 16 | /** 17 | * Represents a 5xx response. 18 | * 19 | * @author Nicolas Grekas 20 | */ 21 | final class ServerException extends \RuntimeException implements ServerExceptionInterface 22 | { 23 | use HttpExceptionTrait; 24 | } 25 | -------------------------------------------------------------------------------- /Exception/TimeoutException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Exception; 13 | 14 | use Symfony\Contracts\HttpClient\Exception\TimeoutExceptionInterface; 15 | 16 | /** 17 | * @author Nicolas Grekas 18 | */ 19 | final class TimeoutException extends TransportException implements TimeoutExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Exception/TransportException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Exception; 13 | 14 | use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; 15 | 16 | /** 17 | * @author Nicolas Grekas 18 | */ 19 | class TransportException extends \RuntimeException implements TransportExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /HttpClient.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient; 13 | 14 | use Amp\Http\Client\Request as AmpRequest; 15 | use Amp\Http\HttpMessage; 16 | use Symfony\Contracts\HttpClient\HttpClientInterface; 17 | 18 | /** 19 | * A factory to instantiate the best possible HTTP client for the runtime. 20 | * 21 | * @author Nicolas Grekas 22 | */ 23 | final class HttpClient 24 | { 25 | /** 26 | * @param array $defaultOptions Default request's options 27 | * @param int $maxHostConnections The maximum number of connections to a single host 28 | * @param int $maxPendingPushes The maximum number of pushed responses to accept in the queue 29 | * 30 | * @see HttpClientInterface::OPTIONS_DEFAULTS for available options 31 | */ 32 | public static function create(array $defaultOptions = [], int $maxHostConnections = 6, int $maxPendingPushes = 50): HttpClientInterface 33 | { 34 | if ($amp = class_exists(AmpRequest::class) && (\PHP_VERSION_ID >= 80400 || !is_subclass_of(AmpRequest::class, HttpMessage::class))) { 35 | if (!\extension_loaded('curl')) { 36 | return new AmpHttpClient($defaultOptions, null, $maxHostConnections, $maxPendingPushes); 37 | } 38 | 39 | // Skip curl when HTTP/2 push is unsupported or buggy, see https://bugs.php.net/77535 40 | if (!\defined('CURLMOPT_PUSHFUNCTION')) { 41 | return new AmpHttpClient($defaultOptions, null, $maxHostConnections, $maxPendingPushes); 42 | } 43 | 44 | static $curlVersion = null; 45 | $curlVersion ??= curl_version(); 46 | 47 | // HTTP/2 push crashes before curl 7.61 48 | if (0x073D00 > $curlVersion['version_number'] || !(\CURL_VERSION_HTTP2 & $curlVersion['features'])) { 49 | return new AmpHttpClient($defaultOptions, null, $maxHostConnections, $maxPendingPushes); 50 | } 51 | } 52 | 53 | if (\extension_loaded('curl')) { 54 | if ('\\' !== \DIRECTORY_SEPARATOR || isset($defaultOptions['cafile']) || isset($defaultOptions['capath']) || \ini_get('curl.cainfo') || \ini_get('openssl.cafile') || \ini_get('openssl.capath')) { 55 | return new CurlHttpClient($defaultOptions, $maxHostConnections, $maxPendingPushes); 56 | } 57 | 58 | @trigger_error('Configure the "curl.cainfo", "openssl.cafile" or "openssl.capath" php.ini setting to enable the CurlHttpClient', \E_USER_WARNING); 59 | } 60 | 61 | if ($amp) { 62 | return new AmpHttpClient($defaultOptions, null, $maxHostConnections, $maxPendingPushes); 63 | } 64 | 65 | @trigger_error((\extension_loaded('curl') ? 'Upgrade' : 'Install').' the curl extension or run "composer require amphp/http-client:^4.2.1" to perform async HTTP operations, including full HTTP/2 support', \E_USER_NOTICE); 66 | 67 | return new NativeHttpClient($defaultOptions, $maxHostConnections); 68 | } 69 | 70 | /** 71 | * Creates a client that adds options (e.g. authentication headers) only when the request URL matches the provided base URI. 72 | */ 73 | public static function createForBaseUri(string $baseUri, array $defaultOptions = [], int $maxHostConnections = 6, int $maxPendingPushes = 50): HttpClientInterface 74 | { 75 | $client = self::create([], $maxHostConnections, $maxPendingPushes); 76 | 77 | return ScopingHttpClient::forBaseUri($client, $baseUri, $defaultOptions); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /HttpOptions.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient; 13 | 14 | use Symfony\Contracts\HttpClient\HttpClientInterface; 15 | 16 | /** 17 | * A helper providing autocompletion for available options. 18 | * 19 | * @see HttpClientInterface for a description of each options. 20 | * 21 | * @author Nicolas Grekas 22 | */ 23 | class HttpOptions 24 | { 25 | private array $options = []; 26 | 27 | public function toArray(): array 28 | { 29 | return $this->options; 30 | } 31 | 32 | /** 33 | * @return $this 34 | */ 35 | public function setAuthBasic(string $user, #[\SensitiveParameter] string $password = ''): static 36 | { 37 | $this->options['auth_basic'] = $user; 38 | 39 | if ('' !== $password) { 40 | $this->options['auth_basic'] .= ':'.$password; 41 | } 42 | 43 | return $this; 44 | } 45 | 46 | /** 47 | * @return $this 48 | */ 49 | public function setAuthBearer(#[\SensitiveParameter] string $token): static 50 | { 51 | $this->options['auth_bearer'] = $token; 52 | 53 | return $this; 54 | } 55 | 56 | /** 57 | * @return $this 58 | */ 59 | public function setQuery(array $query): static 60 | { 61 | $this->options['query'] = $query; 62 | 63 | return $this; 64 | } 65 | 66 | /** 67 | * @return $this 68 | */ 69 | public function setHeader(string $key, string $value): static 70 | { 71 | $this->options['headers'][$key] = $value; 72 | 73 | return $this; 74 | } 75 | 76 | /** 77 | * @return $this 78 | */ 79 | public function setHeaders(iterable $headers): static 80 | { 81 | $this->options['headers'] = $headers; 82 | 83 | return $this; 84 | } 85 | 86 | /** 87 | * @param array|string|resource|\Traversable|\Closure $body 88 | * 89 | * @return $this 90 | */ 91 | public function setBody(mixed $body): static 92 | { 93 | $this->options['body'] = $body; 94 | 95 | return $this; 96 | } 97 | 98 | /** 99 | * @return $this 100 | */ 101 | public function setJson(mixed $json): static 102 | { 103 | $this->options['json'] = $json; 104 | 105 | return $this; 106 | } 107 | 108 | /** 109 | * @return $this 110 | */ 111 | public function setUserData(mixed $data): static 112 | { 113 | $this->options['user_data'] = $data; 114 | 115 | return $this; 116 | } 117 | 118 | /** 119 | * @return $this 120 | */ 121 | public function setMaxRedirects(int $max): static 122 | { 123 | $this->options['max_redirects'] = $max; 124 | 125 | return $this; 126 | } 127 | 128 | /** 129 | * @return $this 130 | */ 131 | public function setHttpVersion(string $version): static 132 | { 133 | $this->options['http_version'] = $version; 134 | 135 | return $this; 136 | } 137 | 138 | /** 139 | * @return $this 140 | */ 141 | public function setBaseUri(string $uri): static 142 | { 143 | $this->options['base_uri'] = $uri; 144 | 145 | return $this; 146 | } 147 | 148 | /** 149 | * @return $this 150 | */ 151 | public function setVars(array $vars): static 152 | { 153 | $this->options['vars'] = $vars; 154 | 155 | return $this; 156 | } 157 | 158 | /** 159 | * @return $this 160 | */ 161 | public function buffer(bool $buffer): static 162 | { 163 | $this->options['buffer'] = $buffer; 164 | 165 | return $this; 166 | } 167 | 168 | /** 169 | * @param callable(int, int, array, \Closure|null=):void $callback 170 | * 171 | * @return $this 172 | */ 173 | public function setOnProgress(callable $callback): static 174 | { 175 | $this->options['on_progress'] = $callback; 176 | 177 | return $this; 178 | } 179 | 180 | /** 181 | * @return $this 182 | */ 183 | public function resolve(array $hostIps): static 184 | { 185 | $this->options['resolve'] = $hostIps; 186 | 187 | return $this; 188 | } 189 | 190 | /** 191 | * @return $this 192 | */ 193 | public function setProxy(string $proxy): static 194 | { 195 | $this->options['proxy'] = $proxy; 196 | 197 | return $this; 198 | } 199 | 200 | /** 201 | * @return $this 202 | */ 203 | public function setNoProxy(string $noProxy): static 204 | { 205 | $this->options['no_proxy'] = $noProxy; 206 | 207 | return $this; 208 | } 209 | 210 | /** 211 | * @return $this 212 | */ 213 | public function setTimeout(float $timeout): static 214 | { 215 | $this->options['timeout'] = $timeout; 216 | 217 | return $this; 218 | } 219 | 220 | /** 221 | * @return $this 222 | */ 223 | public function setMaxDuration(float $maxDuration): static 224 | { 225 | $this->options['max_duration'] = $maxDuration; 226 | 227 | return $this; 228 | } 229 | 230 | /** 231 | * @return $this 232 | */ 233 | public function bindTo(string $bindto): static 234 | { 235 | $this->options['bindto'] = $bindto; 236 | 237 | return $this; 238 | } 239 | 240 | /** 241 | * @return $this 242 | */ 243 | public function verifyPeer(bool $verify): static 244 | { 245 | $this->options['verify_peer'] = $verify; 246 | 247 | return $this; 248 | } 249 | 250 | /** 251 | * @return $this 252 | */ 253 | public function verifyHost(bool $verify): static 254 | { 255 | $this->options['verify_host'] = $verify; 256 | 257 | return $this; 258 | } 259 | 260 | /** 261 | * @return $this 262 | */ 263 | public function setCaFile(string $cafile): static 264 | { 265 | $this->options['cafile'] = $cafile; 266 | 267 | return $this; 268 | } 269 | 270 | /** 271 | * @return $this 272 | */ 273 | public function setCaPath(string $capath): static 274 | { 275 | $this->options['capath'] = $capath; 276 | 277 | return $this; 278 | } 279 | 280 | /** 281 | * @return $this 282 | */ 283 | public function setLocalCert(string $cert): static 284 | { 285 | $this->options['local_cert'] = $cert; 286 | 287 | return $this; 288 | } 289 | 290 | /** 291 | * @return $this 292 | */ 293 | public function setLocalPk(string $pk): static 294 | { 295 | $this->options['local_pk'] = $pk; 296 | 297 | return $this; 298 | } 299 | 300 | /** 301 | * @return $this 302 | */ 303 | public function setPassphrase(string $passphrase): static 304 | { 305 | $this->options['passphrase'] = $passphrase; 306 | 307 | return $this; 308 | } 309 | 310 | /** 311 | * @return $this 312 | */ 313 | public function setCiphers(string $ciphers): static 314 | { 315 | $this->options['ciphers'] = $ciphers; 316 | 317 | return $this; 318 | } 319 | 320 | /** 321 | * @return $this 322 | */ 323 | public function setPeerFingerprint(string|array $fingerprint): static 324 | { 325 | $this->options['peer_fingerprint'] = $fingerprint; 326 | 327 | return $this; 328 | } 329 | 330 | /** 331 | * @return $this 332 | */ 333 | public function capturePeerCertChain(bool $capture): static 334 | { 335 | $this->options['capture_peer_cert_chain'] = $capture; 336 | 337 | return $this; 338 | } 339 | 340 | /** 341 | * @return $this 342 | */ 343 | public function setExtra(string $name, mixed $value): static 344 | { 345 | $this->options['extra'][$name] = $value; 346 | 347 | return $this; 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /Internal/AmpBodyV4.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Internal; 13 | 14 | use Amp\ByteStream\InputStream; 15 | use Amp\ByteStream\ResourceInputStream; 16 | use Amp\Http\Client\RequestBody; 17 | use Amp\Promise; 18 | use Amp\Success; 19 | use Symfony\Component\HttpClient\Exception\TransportException; 20 | 21 | /** 22 | * @author Nicolas Grekas 23 | * 24 | * @internal 25 | */ 26 | class AmpBodyV4 implements RequestBody, InputStream 27 | { 28 | private ResourceInputStream|\Closure|string $body; 29 | private array $info; 30 | private ?int $offset = 0; 31 | private int $length = -1; 32 | private ?int $uploaded = null; 33 | 34 | /** 35 | * @param \Closure|resource|string $body 36 | */ 37 | public function __construct( 38 | $body, 39 | &$info, 40 | private \Closure $onProgress, 41 | ) { 42 | $this->info = &$info; 43 | 44 | if (\is_resource($body)) { 45 | $this->offset = ftell($body); 46 | $this->length = fstat($body)['size']; 47 | $this->body = new ResourceInputStream($body); 48 | } elseif (\is_string($body)) { 49 | $this->length = \strlen($body); 50 | $this->body = $body; 51 | } else { 52 | $this->body = $body; 53 | } 54 | } 55 | 56 | public function createBodyStream(): InputStream 57 | { 58 | if (null !== $this->uploaded) { 59 | $this->uploaded = null; 60 | 61 | if (\is_string($this->body)) { 62 | $this->offset = 0; 63 | } elseif ($this->body instanceof ResourceInputStream) { 64 | fseek($this->body->getResource(), $this->offset); 65 | } 66 | } 67 | 68 | return $this; 69 | } 70 | 71 | public function getHeaders(): Promise 72 | { 73 | return new Success([]); 74 | } 75 | 76 | public function getBodyLength(): Promise 77 | { 78 | return new Success($this->length - $this->offset); 79 | } 80 | 81 | public function read(): Promise 82 | { 83 | $this->info['size_upload'] += $this->uploaded; 84 | $this->uploaded = 0; 85 | ($this->onProgress)(); 86 | 87 | $chunk = $this->doRead(); 88 | $chunk->onResolve(function ($e, $data) { 89 | if (null !== $data) { 90 | $this->uploaded = \strlen($data); 91 | } else { 92 | $this->info['upload_content_length'] = $this->info['size_upload']; 93 | } 94 | }); 95 | 96 | return $chunk; 97 | } 98 | 99 | public static function rewind(RequestBody $body): RequestBody 100 | { 101 | if (!$body instanceof self) { 102 | return $body; 103 | } 104 | 105 | $body->uploaded = null; 106 | 107 | if ($body->body instanceof ResourceInputStream) { 108 | fseek($body->body->getResource(), $body->offset); 109 | 110 | return new $body($body->body, $body->info, $body->onProgress); 111 | } 112 | 113 | if (\is_string($body->body)) { 114 | $body->offset = 0; 115 | } 116 | 117 | return $body; 118 | } 119 | 120 | private function doRead(): Promise 121 | { 122 | if ($this->body instanceof ResourceInputStream) { 123 | return $this->body->read(); 124 | } 125 | 126 | if (null === $this->offset || !$this->length) { 127 | return new Success(); 128 | } 129 | 130 | if (\is_string($this->body)) { 131 | $this->offset = null; 132 | 133 | return new Success($this->body); 134 | } 135 | 136 | if ('' === $data = ($this->body)(16372)) { 137 | $this->offset = null; 138 | 139 | return new Success(); 140 | } 141 | 142 | if (!\is_string($data)) { 143 | throw new TransportException(\sprintf('Return value of the "body" option callback must be string, "%s" returned.', get_debug_type($data))); 144 | } 145 | 146 | return new Success($data); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Internal/AmpBodyV5.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Internal; 13 | 14 | use Amp\ByteStream\ReadableBuffer; 15 | use Amp\ByteStream\ReadableIterableStream; 16 | use Amp\ByteStream\ReadableResourceStream; 17 | use Amp\ByteStream\ReadableStream; 18 | use Amp\Cancellation; 19 | use Amp\Http\Client\HttpContent; 20 | use Symfony\Component\HttpClient\Exception\TransportException; 21 | 22 | /** 23 | * @author Nicolas Grekas 24 | * 25 | * @internal 26 | */ 27 | class AmpBodyV5 implements HttpContent, ReadableStream, \IteratorAggregate 28 | { 29 | private ReadableStream $body; 30 | private ?string $content; 31 | private array $info; 32 | private ?int $offset = 0; 33 | private int $length = -1; 34 | private ?int $uploaded = null; 35 | 36 | /** 37 | * @param \Closure|resource|string $body 38 | */ 39 | public function __construct( 40 | $body, 41 | &$info, 42 | private \Closure $onProgress, 43 | ) { 44 | $this->info = &$info; 45 | 46 | if (\is_resource($body)) { 47 | $this->offset = ftell($body); 48 | $this->length = fstat($body)['size']; 49 | $this->body = new ReadableResourceStream($body); 50 | } elseif (\is_string($body)) { 51 | $this->length = \strlen($body); 52 | $this->body = new ReadableBuffer($body); 53 | $this->content = $body; 54 | } else { 55 | $this->body = new ReadableIterableStream((static function () use ($body) { 56 | while ('' !== $data = ($body)(16372)) { 57 | if (!\is_string($data)) { 58 | throw new TransportException(\sprintf('Return value of the "body" option callback must be string, "%s" returned.', get_debug_type($data))); 59 | } 60 | 61 | yield $data; 62 | } 63 | })()); 64 | } 65 | } 66 | 67 | public function getContent(): ReadableStream 68 | { 69 | if (null !== $this->uploaded) { 70 | $this->uploaded = null; 71 | 72 | if (\is_string($this->body)) { 73 | $this->offset = 0; 74 | } elseif ($this->body instanceof ReadableResourceStream) { 75 | fseek($this->body->getResource(), $this->offset); 76 | } 77 | } 78 | 79 | return $this; 80 | } 81 | 82 | public function getContentType(): ?string 83 | { 84 | return null; 85 | } 86 | 87 | public function getContentLength(): ?int 88 | { 89 | return 0 <= $this->length ? $this->length - $this->offset : null; 90 | } 91 | 92 | public function read(?Cancellation $cancellation = null): ?string 93 | { 94 | $this->info['size_upload'] += $this->uploaded; 95 | $this->uploaded = 0; 96 | ($this->onProgress)(); 97 | 98 | if (null !== $data = $this->body->read($cancellation)) { 99 | $this->uploaded = \strlen($data); 100 | } else { 101 | $this->info['upload_content_length'] = $this->info['size_upload']; 102 | } 103 | 104 | return $data; 105 | } 106 | 107 | public function isReadable(): bool 108 | { 109 | return $this->body->isReadable(); 110 | } 111 | 112 | public function close(): void 113 | { 114 | $this->body->close(); 115 | } 116 | 117 | public function isClosed(): bool 118 | { 119 | return $this->body->isClosed(); 120 | } 121 | 122 | public function onClose(\Closure $onClose): void 123 | { 124 | $this->body->onClose($onClose); 125 | } 126 | 127 | public function getIterator(): \Traversable 128 | { 129 | return $this->body; 130 | } 131 | 132 | public static function rewind(HttpContent $body): HttpContent 133 | { 134 | if (!$body instanceof self) { 135 | return $body; 136 | } 137 | 138 | $body->uploaded = null; 139 | 140 | if ($body->body instanceof ReadableResourceStream && !$body->body->isClosed()) { 141 | fseek($body->body->getResource(), $body->offset); 142 | } 143 | 144 | if ($body->body instanceof ReadableBuffer) { 145 | return new $body($body->content, $body->info, $body->onProgress); 146 | } 147 | 148 | return $body; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /Internal/AmpClientStateV4.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Internal; 13 | 14 | use Amp\CancellationToken; 15 | use Amp\Deferred; 16 | use Amp\Http\Client\Connection\ConnectionLimitingPool; 17 | use Amp\Http\Client\Connection\DefaultConnectionFactory; 18 | use Amp\Http\Client\InterceptedHttpClient; 19 | use Amp\Http\Client\Interceptor\RetryRequests; 20 | use Amp\Http\Client\PooledHttpClient; 21 | use Amp\Http\Client\Request; 22 | use Amp\Http\Client\Response; 23 | use Amp\Http\Tunnel\Http1TunnelConnector; 24 | use Amp\Http\Tunnel\Https1TunnelConnector; 25 | use Amp\Promise; 26 | use Amp\Socket\Certificate; 27 | use Amp\Socket\ClientTlsContext; 28 | use Amp\Socket\ConnectContext; 29 | use Amp\Socket\Connector; 30 | use Amp\Socket\DnsConnector; 31 | use Amp\Socket\SocketAddress; 32 | use Amp\Success; 33 | use Psr\Log\LoggerInterface; 34 | 35 | /** 36 | * Internal representation of the Amp client's state. 37 | * 38 | * @author Nicolas Grekas 39 | * 40 | * @internal 41 | */ 42 | final class AmpClientStateV4 extends ClientState 43 | { 44 | public array $dnsCache = []; 45 | public int $responseCount = 0; 46 | public array $pushedResponses = []; 47 | 48 | private array $clients = []; 49 | private \Closure $clientConfigurator; 50 | 51 | public function __construct( 52 | ?callable $clientConfigurator, 53 | private int $maxHostConnections, 54 | private int $maxPendingPushes, 55 | private ?LoggerInterface &$logger, 56 | ) { 57 | $clientConfigurator ??= static fn (PooledHttpClient $client) => new InterceptedHttpClient($client, new RetryRequests(2)); 58 | $this->clientConfigurator = $clientConfigurator(...); 59 | } 60 | 61 | /** 62 | * @return Promise 63 | */ 64 | public function request(array $options, Request $request, CancellationToken $cancellation, array &$info, \Closure $onProgress, &$handle): Promise 65 | { 66 | if ($options['proxy']) { 67 | if ($request->hasHeader('proxy-authorization')) { 68 | $options['proxy']['auth'] = $request->getHeader('proxy-authorization'); 69 | } 70 | 71 | // Matching "no_proxy" should follow the behavior of curl 72 | $host = $request->getUri()->getHost(); 73 | foreach ($options['proxy']['no_proxy'] as $rule) { 74 | $dotRule = '.'.ltrim($rule, '.'); 75 | 76 | if ('*' === $rule || $host === $rule || str_ends_with($host, $dotRule)) { 77 | $options['proxy'] = null; 78 | break; 79 | } 80 | } 81 | } 82 | 83 | $request = clone $request; 84 | 85 | if ($request->hasHeader('proxy-authorization')) { 86 | $request->removeHeader('proxy-authorization'); 87 | } 88 | 89 | if ($options['capture_peer_cert_chain']) { 90 | $info['peer_certificate_chain'] = []; 91 | } 92 | 93 | $request->addEventListener(new AmpListenerV4($info, $options['peer_fingerprint']['pin-sha256'] ?? [], $onProgress, $handle)); 94 | $request->setPushHandler(fn ($request, $response): Promise => $this->handlePush($request, $response, $options)); 95 | 96 | ($request->hasHeader('content-length') ? new Success((int) $request->getHeader('content-length')) : $request->getBody()->getBodyLength()) 97 | ->onResolve(static function ($e, $bodySize) use (&$info) { 98 | if (null !== $bodySize && 0 <= $bodySize) { 99 | $info['upload_content_length'] = ((1 + $info['upload_content_length']) ?? 1) - 1 + $bodySize; 100 | } 101 | }); 102 | 103 | [$client, $connector] = $this->getClient($options); 104 | $response = $client->request($request, $cancellation); 105 | $response->onResolve(static function ($e) use ($connector, &$handle) { 106 | if (null === $e) { 107 | $handle = $connector->handle; 108 | } 109 | }); 110 | 111 | return $response; 112 | } 113 | 114 | private function getClient(array $options): array 115 | { 116 | $options = [ 117 | 'bindto' => $options['bindto'] ?: '0', 118 | 'verify_peer' => $options['verify_peer'], 119 | 'capath' => $options['capath'], 120 | 'cafile' => $options['cafile'], 121 | 'local_cert' => $options['local_cert'], 122 | 'local_pk' => $options['local_pk'], 123 | 'ciphers' => $options['ciphers'], 124 | 'capture_peer_cert_chain' => $options['capture_peer_cert_chain'] || $options['peer_fingerprint'], 125 | 'proxy' => $options['proxy'], 126 | 'crypto_method' => $options['crypto_method'], 127 | ]; 128 | 129 | $key = hash('xxh128', serialize($options)); 130 | 131 | if (isset($this->clients[$key])) { 132 | return $this->clients[$key]; 133 | } 134 | 135 | $context = new ClientTlsContext(''); 136 | $options['verify_peer'] || $context = $context->withoutPeerVerification(); 137 | $options['cafile'] && $context = $context->withCaFile($options['cafile']); 138 | $options['capath'] && $context = $context->withCaPath($options['capath']); 139 | $options['local_cert'] && $context = $context->withCertificate(new Certificate($options['local_cert'], $options['local_pk'])); 140 | $options['ciphers'] && $context = $context->withCiphers($options['ciphers']); 141 | $options['capture_peer_cert_chain'] && $context = $context->withPeerCapturing(); 142 | $options['crypto_method'] && $context = $context->withMinimumVersion($options['crypto_method']); 143 | 144 | $connector = $handleConnector = new class implements Connector { 145 | public DnsConnector $connector; 146 | public string $uri; 147 | /** @var resource|null */ 148 | public $handle; 149 | 150 | public function connect(string $uri, ?ConnectContext $context = null, ?CancellationToken $token = null): Promise 151 | { 152 | $result = $this->connector->connect($this->uri ?? $uri, $context, $token); 153 | $result->onResolve(function ($e, $socket) { 154 | $this->handle = $socket?->getResource(); 155 | }); 156 | 157 | return $result; 158 | } 159 | }; 160 | $connector->connector = new DnsConnector(new AmpResolverV4($this->dnsCache)); 161 | 162 | $context = (new ConnectContext()) 163 | ->withTcpNoDelay() 164 | ->withTlsContext($context); 165 | 166 | if ($options['bindto']) { 167 | if (file_exists($options['bindto'])) { 168 | $connector->uri = 'unix://'.$options['bindto']; 169 | } else { 170 | $context = $context->withBindTo($options['bindto']); 171 | } 172 | } 173 | 174 | if ($options['proxy']) { 175 | $proxyUrl = parse_url($options['proxy']['url']); 176 | $proxySocket = new SocketAddress($proxyUrl['host'], $proxyUrl['port']); 177 | $proxyHeaders = $options['proxy']['auth'] ? ['Proxy-Authorization' => $options['proxy']['auth']] : []; 178 | 179 | if ('ssl' === $proxyUrl['scheme']) { 180 | $connector = new Https1TunnelConnector($proxySocket, $context->getTlsContext(), $proxyHeaders, $connector); 181 | } else { 182 | $connector = new Http1TunnelConnector($proxySocket, $proxyHeaders, $connector); 183 | } 184 | } 185 | 186 | $maxHostConnections = 0 < $this->maxHostConnections ? $this->maxHostConnections : \PHP_INT_MAX; 187 | $pool = new DefaultConnectionFactory($connector, $context); 188 | $pool = ConnectionLimitingPool::byAuthority($maxHostConnections, $pool); 189 | 190 | return $this->clients[$key] = [($this->clientConfigurator)(new PooledHttpClient($pool)), $handleConnector]; 191 | } 192 | 193 | private function handlePush(Request $request, Promise $response, array $options): Promise 194 | { 195 | $deferred = new Deferred(); 196 | $authority = $request->getUri()->getAuthority(); 197 | 198 | if ($this->maxPendingPushes <= \count($this->pushedResponses[$authority] ?? [])) { 199 | $fifoUrl = key($this->pushedResponses[$authority]); 200 | unset($this->pushedResponses[$authority][$fifoUrl]); 201 | $this->logger?->debug(\sprintf('Evicting oldest pushed response: "%s"', $fifoUrl)); 202 | } 203 | 204 | $url = (string) $request->getUri(); 205 | $this->logger?->debug(\sprintf('Queueing pushed response: "%s"', $url)); 206 | $this->pushedResponses[$authority][] = [$url, $deferred, $request, $response, [ 207 | 'proxy' => $options['proxy'], 208 | 'bindto' => $options['bindto'], 209 | 'local_cert' => $options['local_cert'], 210 | 'local_pk' => $options['local_pk'], 211 | ]]; 212 | 213 | return $deferred->promise(); 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /Internal/AmpClientStateV5.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Internal; 13 | 14 | use Amp\ByteStream\ResourceStream; 15 | use Amp\Cancellation; 16 | use Amp\DeferredFuture; 17 | use Amp\Future; 18 | use Amp\Http\Client\Connection\ConnectionLimitingPool; 19 | use Amp\Http\Client\Connection\DefaultConnectionFactory; 20 | use Amp\Http\Client\InterceptedHttpClient; 21 | use Amp\Http\Client\Interceptor\RetryRequests; 22 | use Amp\Http\Client\PooledHttpClient; 23 | use Amp\Http\Client\Request; 24 | use Amp\Http\Client\Response; 25 | use Amp\Http\Tunnel\Http1TunnelConnector; 26 | use Amp\Http\Tunnel\Https1TunnelConnector; 27 | use Amp\Socket\Certificate; 28 | use Amp\Socket\ClientTlsContext; 29 | use Amp\Socket\ConnectContext; 30 | use Amp\Socket\DnsSocketConnector; 31 | use Amp\Socket\InternetAddress; 32 | use Amp\Socket\Socket; 33 | use Amp\Socket\SocketAddress; 34 | use Amp\Socket\SocketConnector; 35 | use Psr\Log\LoggerInterface; 36 | 37 | /** 38 | * Internal representation of the Amp client's state. 39 | * 40 | * @author Nicolas Grekas 41 | * 42 | * @internal 43 | */ 44 | final class AmpClientStateV5 extends ClientState 45 | { 46 | public array $dnsCache = []; 47 | public int $responseCount = 0; 48 | public array $pushedResponses = []; 49 | 50 | private array $clients = []; 51 | private \Closure $clientConfigurator; 52 | 53 | public function __construct( 54 | ?callable $clientConfigurator, 55 | private int $maxHostConnections, 56 | private int $maxPendingPushes, 57 | private ?LoggerInterface &$logger, 58 | ) { 59 | $clientConfigurator ??= static fn (PooledHttpClient $client) => new InterceptedHttpClient($client, new RetryRequests(2), []); 60 | $this->clientConfigurator = $clientConfigurator(...); 61 | } 62 | 63 | public function request(array $options, Request $request, Cancellation $cancellation, array &$info, \Closure $onProgress, &$handle): Response 64 | { 65 | if ($options['proxy']) { 66 | if ($request->hasHeader('proxy-authorization')) { 67 | $options['proxy']['auth'] = $request->getHeader('proxy-authorization'); 68 | } 69 | 70 | // Matching "no_proxy" should follow the behavior of curl 71 | $host = $request->getUri()->getHost(); 72 | foreach ($options['proxy']['no_proxy'] as $rule) { 73 | $dotRule = '.'.ltrim($rule, '.'); 74 | 75 | if ('*' === $rule || $host === $rule || str_ends_with($host, $dotRule)) { 76 | $options['proxy'] = null; 77 | break; 78 | } 79 | } 80 | } 81 | 82 | if ($request->hasHeader('proxy-authorization')) { 83 | $request->removeHeader('proxy-authorization'); 84 | } 85 | 86 | if ($options['capture_peer_cert_chain']) { 87 | $info['peer_certificate_chain'] = []; 88 | } 89 | 90 | $request->addEventListener(new AmpListenerV5($info, $options['peer_fingerprint']['pin-sha256'] ?? [], $onProgress, $handle)); 91 | $request->setPushHandler(fn ($request, $response) => $this->handlePush($request, $response, $options)); 92 | 93 | if (0 <= $bodySize = $request->hasHeader('content-length') ? (int) $request->getHeader('content-length') : $request->getBody()->getContentLength() ?? -1) { 94 | $info['upload_content_length'] = ((1 + $info['upload_content_length']) ?? 1) - 1 + $bodySize; 95 | } 96 | 97 | [$client, $connector] = $this->getClient($options); 98 | $response = $client->request($request, $cancellation); 99 | $handle = $connector->handle; 100 | 101 | return $response; 102 | } 103 | 104 | private function getClient(array $options): array 105 | { 106 | $options = [ 107 | 'bindto' => $options['bindto'] ?: '0', 108 | 'verify_peer' => $options['verify_peer'], 109 | 'capath' => $options['capath'], 110 | 'cafile' => $options['cafile'], 111 | 'local_cert' => $options['local_cert'], 112 | 'local_pk' => $options['local_pk'], 113 | 'ciphers' => $options['ciphers'], 114 | 'capture_peer_cert_chain' => $options['capture_peer_cert_chain'] || $options['peer_fingerprint'], 115 | 'proxy' => $options['proxy'], 116 | 'crypto_method' => $options['crypto_method'], 117 | ]; 118 | 119 | $key = hash('xxh128', serialize($options)); 120 | 121 | if (isset($this->clients[$key])) { 122 | return $this->clients[$key]; 123 | } 124 | 125 | $context = new ClientTlsContext(''); 126 | $options['verify_peer'] || $context = $context->withoutPeerVerification(); 127 | $options['cafile'] && $context = $context->withCaFile($options['cafile']); 128 | $options['capath'] && $context = $context->withCaPath($options['capath']); 129 | $options['local_cert'] && $context = $context->withCertificate(new Certificate($options['local_cert'], $options['local_pk'])); 130 | $options['ciphers'] && $context = $context->withCiphers($options['ciphers']); 131 | $options['capture_peer_cert_chain'] && $context = $context->withPeerCapturing(); 132 | $options['crypto_method'] && $context = $context->withMinimumVersion($options['crypto_method']); 133 | 134 | $connector = $handleConnector = new class implements SocketConnector { 135 | public DnsSocketConnector $connector; 136 | public string $uri; 137 | /** @var resource|null */ 138 | public $handle; 139 | 140 | public function connect(SocketAddress|string $uri, ?ConnectContext $context = null, ?Cancellation $cancellation = null): Socket 141 | { 142 | $socket = $this->connector->connect($this->uri ?? $uri, $context, $cancellation); 143 | $this->handle = $socket instanceof ResourceStream ? $socket->getResource() : false; 144 | 145 | return $socket; 146 | } 147 | }; 148 | $connector->connector = new DnsSocketConnector(new AmpResolverV5($this->dnsCache)); 149 | 150 | $context = (new ConnectContext()) 151 | ->withTcpNoDelay() 152 | ->withTlsContext($context); 153 | 154 | if ($options['bindto']) { 155 | if (file_exists($options['bindto'])) { 156 | $connector->uri = 'unix://'.$options['bindto']; 157 | } else { 158 | $context = $context->withBindTo($options['bindto']); 159 | } 160 | } 161 | 162 | if ($options['proxy']) { 163 | $proxyUrl = parse_url($options['proxy']['url']); 164 | $proxySocket = new InternetAddress($proxyUrl['host'], $proxyUrl['port']); 165 | $proxyHeaders = $options['proxy']['auth'] ? ['Proxy-Authorization' => $options['proxy']['auth']] : []; 166 | 167 | if ('ssl' === $proxyUrl['scheme']) { 168 | $connector = new Https1TunnelConnector($proxySocket, $context->getTlsContext(), $proxyHeaders, $connector); 169 | } else { 170 | $connector = new Http1TunnelConnector($proxySocket, $proxyHeaders, $connector); 171 | } 172 | } 173 | 174 | $maxHostConnections = 0 < $this->maxHostConnections ? $this->maxHostConnections : \PHP_INT_MAX; 175 | $pool = new DefaultConnectionFactory($connector, $context); 176 | $pool = ConnectionLimitingPool::byAuthority($maxHostConnections, $pool); 177 | 178 | return $this->clients[$key] = [($this->clientConfigurator)(new PooledHttpClient($pool)), $handleConnector]; 179 | } 180 | 181 | private function handlePush(Request $request, Future $response, array $options): void 182 | { 183 | $deferred = new DeferredFuture(); 184 | $authority = $request->getUri()->getAuthority(); 185 | 186 | if ($this->maxPendingPushes <= \count($this->pushedResponses[$authority] ?? [])) { 187 | $fifoUrl = key($this->pushedResponses[$authority]); 188 | unset($this->pushedResponses[$authority][$fifoUrl]); 189 | $this->logger?->debug(\sprintf('Evicting oldest pushed response: "%s"', $fifoUrl)); 190 | } 191 | 192 | $url = (string) $request->getUri(); 193 | $this->logger?->debug(\sprintf('Queueing pushed response: "%s"', $url)); 194 | $this->pushedResponses[$authority][] = [$url, $deferred, $request, $response, [ 195 | 'proxy' => $options['proxy'], 196 | 'bindto' => $options['bindto'], 197 | 'local_cert' => $options['local_cert'], 198 | 'local_pk' => $options['local_pk'], 199 | ]]; 200 | 201 | $deferred->getFuture()->await(); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /Internal/AmpListenerV4.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Internal; 13 | 14 | use Amp\Http\Client\Connection\Stream; 15 | use Amp\Http\Client\EventListener; 16 | use Amp\Http\Client\Request; 17 | use Amp\Promise; 18 | use Amp\Success; 19 | use Symfony\Component\HttpClient\Exception\TransportException; 20 | 21 | /** 22 | * @author Nicolas Grekas 23 | * 24 | * @internal 25 | */ 26 | class AmpListenerV4 implements EventListener 27 | { 28 | private array $info; 29 | 30 | /** 31 | * @param resource|null $handle 32 | */ 33 | public function __construct( 34 | array &$info, 35 | private array $pinSha256, 36 | private \Closure $onProgress, 37 | private &$handle, 38 | ) { 39 | $info += [ 40 | 'connect_time' => 0.0, 41 | 'pretransfer_time' => 0.0, 42 | 'starttransfer_time' => 0.0, 43 | 'total_time' => 0.0, 44 | 'namelookup_time' => 0.0, 45 | 'primary_ip' => '', 46 | 'primary_port' => 0, 47 | ]; 48 | 49 | $this->info = &$info; 50 | } 51 | 52 | public function startRequest(Request $request): Promise 53 | { 54 | $this->info['start_time'] ??= microtime(true); 55 | ($this->onProgress)(); 56 | 57 | return new Success(); 58 | } 59 | 60 | public function startDnsResolution(Request $request): Promise 61 | { 62 | ($this->onProgress)(); 63 | 64 | return new Success(); 65 | } 66 | 67 | public function startConnectionCreation(Request $request): Promise 68 | { 69 | ($this->onProgress)(); 70 | 71 | return new Success(); 72 | } 73 | 74 | public function startTlsNegotiation(Request $request): Promise 75 | { 76 | ($this->onProgress)(); 77 | 78 | return new Success(); 79 | } 80 | 81 | public function startSendingRequest(Request $request, Stream $stream): Promise 82 | { 83 | $host = $stream->getRemoteAddress()->getHost(); 84 | $this->info['primary_ip'] = $host; 85 | 86 | if (str_contains($host, ':')) { 87 | $host = '['.$host.']'; 88 | } 89 | 90 | $this->info['primary_port'] = $stream->getRemoteAddress()->getPort(); 91 | $this->info['pretransfer_time'] = microtime(true) - $this->info['start_time']; 92 | $this->info['debug'] .= \sprintf("* Connected to %s (%s) port %d\n", $request->getUri()->getHost(), $host, $this->info['primary_port']); 93 | 94 | if ((isset($this->info['peer_certificate_chain']) || $this->pinSha256) && null !== $tlsInfo = $stream->getTlsInfo()) { 95 | foreach ($tlsInfo->getPeerCertificates() as $cert) { 96 | $this->info['peer_certificate_chain'][] = openssl_x509_read($cert->toPem()); 97 | } 98 | 99 | if ($this->pinSha256) { 100 | $pin = openssl_pkey_get_public($this->info['peer_certificate_chain'][0]); 101 | $pin = openssl_pkey_get_details($pin)['key']; 102 | $pin = \array_slice(explode("\n", $pin), 1, -2); 103 | $pin = base64_decode(implode('', $pin)); 104 | $pin = base64_encode(hash('sha256', $pin, true)); 105 | 106 | if (!\in_array($pin, $this->pinSha256, true)) { 107 | throw new TransportException(\sprintf('SSL public key does not match pinned public key for "%s".', $this->info['url'])); 108 | } 109 | } 110 | } 111 | ($this->onProgress)(); 112 | 113 | $uri = $request->getUri(); 114 | $requestUri = $uri->getPath() ?: '/'; 115 | 116 | if ('' !== $query = $uri->getQuery()) { 117 | $requestUri .= '?'.$query; 118 | } 119 | 120 | if ('CONNECT' === $method = $request->getMethod()) { 121 | $requestUri = $uri->getHost().': '.($uri->getPort() ?? ('https' === $uri->getScheme() ? 443 : 80)); 122 | } 123 | 124 | $this->info['debug'] .= \sprintf("> %s %s HTTP/%s \r\n", $method, $requestUri, $request->getProtocolVersions()[0]); 125 | 126 | foreach ($request->getRawHeaders() as [$name, $value]) { 127 | $this->info['debug'] .= $name.': '.$value."\r\n"; 128 | } 129 | $this->info['debug'] .= "\r\n"; 130 | 131 | return new Success(); 132 | } 133 | 134 | public function completeSendingRequest(Request $request, Stream $stream): Promise 135 | { 136 | ($this->onProgress)(); 137 | 138 | return new Success(); 139 | } 140 | 141 | public function startReceivingResponse(Request $request, Stream $stream): Promise 142 | { 143 | $this->info['starttransfer_time'] = microtime(true) - $this->info['start_time']; 144 | ($this->onProgress)(); 145 | 146 | return new Success(); 147 | } 148 | 149 | public function completeReceivingResponse(Request $request, Stream $stream): Promise 150 | { 151 | $this->handle = null; 152 | ($this->onProgress)(); 153 | 154 | return new Success(); 155 | } 156 | 157 | public function completeDnsResolution(Request $request): Promise 158 | { 159 | $this->info['namelookup_time'] = microtime(true) - $this->info['start_time']; 160 | ($this->onProgress)(); 161 | 162 | return new Success(); 163 | } 164 | 165 | public function completeConnectionCreation(Request $request): Promise 166 | { 167 | $this->info['connect_time'] = microtime(true) - $this->info['start_time']; 168 | ($this->onProgress)(); 169 | 170 | return new Success(); 171 | } 172 | 173 | public function completeTlsNegotiation(Request $request): Promise 174 | { 175 | ($this->onProgress)(); 176 | 177 | return new Success(); 178 | } 179 | 180 | public function abort(Request $request, \Throwable $cause): Promise 181 | { 182 | return new Success(); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /Internal/AmpListenerV5.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Internal; 13 | 14 | use Amp\Http\Client\ApplicationInterceptor; 15 | use Amp\Http\Client\Connection\Connection; 16 | use Amp\Http\Client\Connection\Stream; 17 | use Amp\Http\Client\EventListener; 18 | use Amp\Http\Client\NetworkInterceptor; 19 | use Amp\Http\Client\Request; 20 | use Amp\Http\Client\Response; 21 | use Amp\Socket\InternetAddress; 22 | use Symfony\Component\HttpClient\Exception\TransportException; 23 | 24 | /** 25 | * @author Nicolas Grekas 26 | * 27 | * @internal 28 | */ 29 | class AmpListenerV5 implements EventListener 30 | { 31 | private array $info; 32 | 33 | /** 34 | * @param resource|null $handle 35 | */ 36 | public function __construct( 37 | array &$info, 38 | private array $pinSha256, 39 | private \Closure $onProgress, 40 | private &$handle, 41 | ) { 42 | $info += [ 43 | 'connect_time' => 0.0, 44 | 'pretransfer_time' => 0.0, 45 | 'starttransfer_time' => 0.0, 46 | 'total_time' => 0.0, 47 | 'namelookup_time' => 0.0, 48 | 'primary_ip' => '', 49 | 'primary_port' => 0, 50 | ]; 51 | 52 | $this->info = &$info; 53 | } 54 | 55 | public function requestStart(Request $request): void 56 | { 57 | $this->info['start_time'] ??= microtime(true); 58 | ($this->onProgress)(); 59 | } 60 | 61 | public function connectionAcquired(Request $request, Connection $connection, int $streamCount): void 62 | { 63 | $this->info['namelookup_time'] = microtime(true) - $this->info['start_time']; // see https://github.com/amphp/socket/issues/114 64 | $this->info['connect_time'] = microtime(true) - $this->info['start_time']; 65 | ($this->onProgress)(); 66 | } 67 | 68 | public function requestHeaderStart(Request $request, Stream $stream): void 69 | { 70 | $host = $stream->getRemoteAddress()->toString(); 71 | if ($stream->getRemoteAddress() instanceof InternetAddress) { 72 | $host = $stream->getRemoteAddress()->getAddress(); 73 | $this->info['primary_port'] = $stream->getRemoteAddress()->getPort(); 74 | } 75 | 76 | $this->info['primary_ip'] = $host; 77 | 78 | if (str_contains($host, ':')) { 79 | $host = '['.$host.']'; 80 | } 81 | 82 | $this->info['pretransfer_time'] = microtime(true) - $this->info['start_time']; 83 | $this->info['debug'] .= \sprintf("* Connected to %s (%s) port %d\n", $request->getUri()->getHost(), $host, $this->info['primary_port']); 84 | 85 | if ((isset($this->info['peer_certificate_chain']) || $this->pinSha256) && null !== $tlsInfo = $stream->getTlsInfo()) { 86 | foreach ($tlsInfo->getPeerCertificates() as $cert) { 87 | $this->info['peer_certificate_chain'][] = openssl_x509_read($cert->toPem()); 88 | } 89 | 90 | if ($this->pinSha256) { 91 | $pin = openssl_pkey_get_public($this->info['peer_certificate_chain'][0]); 92 | $pin = openssl_pkey_get_details($pin)['key']; 93 | $pin = \array_slice(explode("\n", $pin), 1, -2); 94 | $pin = base64_decode(implode('', $pin)); 95 | $pin = base64_encode(hash('sha256', $pin, true)); 96 | 97 | if (!\in_array($pin, $this->pinSha256, true)) { 98 | throw new TransportException(\sprintf('SSL public key does not match pinned public key for "%s".', $this->info['url'])); 99 | } 100 | } 101 | } 102 | ($this->onProgress)(); 103 | 104 | $uri = $request->getUri(); 105 | $requestUri = $uri->getPath() ?: '/'; 106 | 107 | if ('' !== $query = $uri->getQuery()) { 108 | $requestUri .= '?'.$query; 109 | } 110 | 111 | if ('CONNECT' === $method = $request->getMethod()) { 112 | $requestUri = $uri->getHost().': '.($uri->getPort() ?? ('https' === $uri->getScheme() ? 443 : 80)); 113 | } 114 | 115 | $this->info['debug'] .= \sprintf("> %s %s HTTP/%s \r\n", $method, $requestUri, $request->getProtocolVersions()[0]); 116 | 117 | foreach ($request->getHeaderPairs() as [$name, $value]) { 118 | $this->info['debug'] .= $name.': '.$value."\r\n"; 119 | } 120 | $this->info['debug'] .= "\r\n"; 121 | } 122 | 123 | public function requestBodyEnd(Request $request, Stream $stream): void 124 | { 125 | ($this->onProgress)(); 126 | } 127 | 128 | public function responseHeaderStart(Request $request, Stream $stream): void 129 | { 130 | ($this->onProgress)(); 131 | } 132 | 133 | public function requestEnd(Request $request, Response $response): void 134 | { 135 | ($this->onProgress)(); 136 | } 137 | 138 | public function requestFailed(Request $request, \Throwable $exception): void 139 | { 140 | $this->handle = null; 141 | ($this->onProgress)(); 142 | } 143 | 144 | public function requestHeaderEnd(Request $request, Stream $stream): void 145 | { 146 | ($this->onProgress)(); 147 | } 148 | 149 | public function requestBodyStart(Request $request, Stream $stream): void 150 | { 151 | ($this->onProgress)(); 152 | } 153 | 154 | public function requestBodyProgress(Request $request, Stream $stream): void 155 | { 156 | ($this->onProgress)(); 157 | } 158 | 159 | public function responseHeaderEnd(Request $request, Stream $stream, Response $response): void 160 | { 161 | ($this->onProgress)(); 162 | } 163 | 164 | public function responseBodyStart(Request $request, Stream $stream, Response $response): void 165 | { 166 | $this->info['starttransfer_time'] = microtime(true) - $this->info['start_time']; 167 | ($this->onProgress)(); 168 | } 169 | 170 | public function responseBodyProgress(Request $request, Stream $stream, Response $response): void 171 | { 172 | ($this->onProgress)(); 173 | } 174 | 175 | public function responseBodyEnd(Request $request, Stream $stream, Response $response): void 176 | { 177 | $this->handle = null; 178 | ($this->onProgress)(); 179 | } 180 | 181 | public function applicationInterceptorStart(Request $request, ApplicationInterceptor $interceptor): void 182 | { 183 | } 184 | 185 | public function applicationInterceptorEnd(Request $request, ApplicationInterceptor $interceptor, Response $response): void 186 | { 187 | } 188 | 189 | public function networkInterceptorStart(Request $request, NetworkInterceptor $interceptor): void 190 | { 191 | } 192 | 193 | public function networkInterceptorEnd(Request $request, NetworkInterceptor $interceptor, Response $response): void 194 | { 195 | } 196 | 197 | public function push(Request $request): void 198 | { 199 | ($this->onProgress)(); 200 | } 201 | 202 | public function requestRejected(Request $request): void 203 | { 204 | $this->handle = null; 205 | ($this->onProgress)(); 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /Internal/AmpResolverV4.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Internal; 13 | 14 | use Amp\Dns; 15 | use Amp\Dns\Record; 16 | use Amp\Promise; 17 | use Amp\Success; 18 | 19 | /** 20 | * Handles local overrides for the DNS resolver. 21 | * 22 | * @author Nicolas Grekas 23 | * 24 | * @internal 25 | */ 26 | class AmpResolverV4 implements Dns\Resolver 27 | { 28 | public function __construct( 29 | private array &$dnsMap, 30 | ) { 31 | } 32 | 33 | public function resolve(string $name, ?int $typeRestriction = null): Promise 34 | { 35 | $recordType = Record::A; 36 | $ip = $this->dnsMap[$name] ?? null; 37 | 38 | if (null !== $ip && str_contains($ip, ':')) { 39 | $recordType = Record::AAAA; 40 | } 41 | if (null === $ip || $recordType !== ($typeRestriction ?? $recordType)) { 42 | return Dns\resolver()->resolve($name, $typeRestriction); 43 | } 44 | 45 | return new Success([new Record($ip, $recordType, null)]); 46 | } 47 | 48 | public function query(string $name, int $type): Promise 49 | { 50 | $recordType = Record::A; 51 | $ip = $this->dnsMap[$name] ?? null; 52 | 53 | if (null !== $ip && str_contains($ip, ':')) { 54 | $recordType = Record::AAAA; 55 | } 56 | if (null === $ip || $recordType !== $type) { 57 | return Dns\resolver()->query($name, $type); 58 | } 59 | 60 | return new Success([new Record($ip, $recordType, null)]); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Internal/AmpResolverV5.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Internal; 13 | 14 | use Amp\Cancellation; 15 | use Amp\Dns; 16 | use Amp\Dns\DnsRecord; 17 | use Amp\Dns\DnsResolver; 18 | 19 | /** 20 | * Handles local overrides for the DNS resolver. 21 | * 22 | * @author Nicolas Grekas 23 | * 24 | * @internal 25 | */ 26 | class AmpResolverV5 implements DnsResolver 27 | { 28 | public function __construct( 29 | private array &$dnsMap, 30 | ) { 31 | } 32 | 33 | public function resolve(string $name, ?int $typeRestriction = null, ?Cancellation $cancellation = null): array 34 | { 35 | $recordType = DnsRecord::A; 36 | $ip = $this->dnsMap[$name] ?? null; 37 | 38 | if (null !== $ip && str_contains($ip, ':')) { 39 | $recordType = DnsRecord::AAAA; 40 | } 41 | 42 | if (null === $ip || $recordType !== ($typeRestriction ?? $recordType)) { 43 | return Dns\resolve($name, $typeRestriction, $cancellation); 44 | } 45 | 46 | return [new DnsRecord($ip, $recordType, null)]; 47 | } 48 | 49 | public function query(string $name, int $type, ?Cancellation $cancellation = null): array 50 | { 51 | $recordType = DnsRecord::A; 52 | $ip = $this->dnsMap[$name] ?? null; 53 | 54 | if (null !== $ip && str_contains($ip, ':')) { 55 | $recordType = DnsRecord::AAAA; 56 | } 57 | 58 | if (null !== $ip || $recordType !== $type) { 59 | return Dns\resolve($name, $type, $cancellation); 60 | } 61 | 62 | return [new DnsRecord($ip, $recordType, null)]; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Internal/Canary.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Internal; 13 | 14 | /** 15 | * @author Nicolas Grekas 16 | * 17 | * @internal 18 | */ 19 | final class Canary 20 | { 21 | public function __construct( 22 | private \Closure $canceller, 23 | ) { 24 | } 25 | 26 | public function cancel(): void 27 | { 28 | if (isset($this->canceller)) { 29 | $canceller = $this->canceller; 30 | unset($this->canceller); 31 | $canceller(); 32 | } 33 | } 34 | 35 | public function __destruct() 36 | { 37 | $this->cancel(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Internal/ClientState.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Internal; 13 | 14 | /** 15 | * Internal representation of the client state. 16 | * 17 | * @author Alexander M. Turek 18 | * 19 | * @internal 20 | */ 21 | class ClientState 22 | { 23 | public array $handlesActivity = []; 24 | public array $openHandles = []; 25 | public ?float $lastTimeout = null; 26 | } 27 | -------------------------------------------------------------------------------- /Internal/CurlClientState.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Internal; 13 | 14 | use Psr\Log\LoggerInterface; 15 | use Symfony\Component\HttpClient\Response\CurlResponse; 16 | 17 | /** 18 | * Internal representation of the cURL client's state. 19 | * 20 | * @author Alexander M. Turek 21 | * 22 | * @internal 23 | */ 24 | final class CurlClientState extends ClientState 25 | { 26 | public ?\CurlMultiHandle $handle; 27 | public ?\CurlShareHandle $share; 28 | public bool $performing = false; 29 | 30 | /** @var PushedResponse[] */ 31 | public array $pushedResponses = []; 32 | public DnsCache $dnsCache; 33 | /** @var float[] */ 34 | public array $pauseExpiries = []; 35 | public int $execCounter = \PHP_INT_MIN; 36 | public ?LoggerInterface $logger = null; 37 | 38 | public static array $curlVersion; 39 | 40 | public function __construct(int $maxHostConnections, int $maxPendingPushes) 41 | { 42 | self::$curlVersion ??= curl_version(); 43 | 44 | $this->handle = curl_multi_init(); 45 | $this->dnsCache = new DnsCache(); 46 | $this->reset(); 47 | 48 | // Don't enable HTTP/1.1 pipelining: it forces responses to be sent in order 49 | if (\defined('CURLPIPE_MULTIPLEX')) { 50 | curl_multi_setopt($this->handle, \CURLMOPT_PIPELINING, \CURLPIPE_MULTIPLEX); 51 | } 52 | if (\defined('CURLMOPT_MAX_HOST_CONNECTIONS') && 0 < $maxHostConnections) { 53 | $maxHostConnections = curl_multi_setopt($this->handle, \CURLMOPT_MAX_HOST_CONNECTIONS, $maxHostConnections) ? 4294967295 : $maxHostConnections; 54 | } 55 | if (\defined('CURLMOPT_MAXCONNECTS') && 0 < $maxHostConnections) { 56 | curl_multi_setopt($this->handle, \CURLMOPT_MAXCONNECTS, $maxHostConnections); 57 | } 58 | 59 | // Skip configuring HTTP/2 push when it's unsupported or buggy, see https://bugs.php.net/77535 60 | if (0 >= $maxPendingPushes) { 61 | return; 62 | } 63 | 64 | // HTTP/2 push crashes before curl 7.61 65 | if (!\defined('CURLMOPT_PUSHFUNCTION') || 0x073D00 > self::$curlVersion['version_number'] || !(\CURL_VERSION_HTTP2 & self::$curlVersion['features'])) { 66 | return; 67 | } 68 | 69 | // Clone to prevent a circular reference 70 | $multi = clone $this; 71 | $multi->handle = null; 72 | $multi->share = null; 73 | $multi->pushedResponses = &$this->pushedResponses; 74 | $multi->logger = &$this->logger; 75 | $multi->handlesActivity = &$this->handlesActivity; 76 | $multi->openHandles = &$this->openHandles; 77 | 78 | curl_multi_setopt($this->handle, \CURLMOPT_PUSHFUNCTION, static fn ($parent, $pushed, array $requestHeaders) => $multi->handlePush($parent, $pushed, $requestHeaders, $maxPendingPushes)); 79 | } 80 | 81 | public function reset(): void 82 | { 83 | foreach ($this->pushedResponses as $url => $response) { 84 | $this->logger?->debug(\sprintf('Unused pushed response: "%s"', $url)); 85 | curl_multi_remove_handle($this->handle, $response->handle); 86 | curl_close($response->handle); 87 | } 88 | 89 | $this->pushedResponses = []; 90 | $this->dnsCache->evictions = $this->dnsCache->evictions ?: $this->dnsCache->removals; 91 | $this->dnsCache->removals = $this->dnsCache->hostnames = []; 92 | 93 | $this->share = curl_share_init(); 94 | 95 | curl_share_setopt($this->share, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_DNS); 96 | curl_share_setopt($this->share, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_SSL_SESSION); 97 | 98 | if (\defined('CURL_LOCK_DATA_CONNECT')) { 99 | curl_share_setopt($this->share, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_CONNECT); 100 | } 101 | } 102 | 103 | private function handlePush($parent, $pushed, array $requestHeaders, int $maxPendingPushes): int 104 | { 105 | $headers = []; 106 | $origin = curl_getinfo($parent, \CURLINFO_EFFECTIVE_URL); 107 | 108 | foreach ($requestHeaders as $h) { 109 | if (false !== $i = strpos($h, ':', 1)) { 110 | $headers[substr($h, 0, $i)][] = substr($h, 1 + $i); 111 | } 112 | } 113 | 114 | if (!isset($headers[':method']) || !isset($headers[':scheme']) || !isset($headers[':authority']) || !isset($headers[':path'])) { 115 | $this->logger?->debug(\sprintf('Rejecting pushed response from "%s": pushed headers are invalid', $origin)); 116 | 117 | return \CURL_PUSH_DENY; 118 | } 119 | 120 | $url = $headers[':scheme'][0].'://'.$headers[':authority'][0]; 121 | 122 | // curl before 7.65 doesn't validate the pushed ":authority" header, 123 | // but this is a MUST in the HTTP/2 RFC; let's restrict pushes to the original host, 124 | // ignoring domains mentioned as alt-name in the certificate for now (same as curl). 125 | if (!str_starts_with($origin, $url.'/')) { 126 | $this->logger?->debug(\sprintf('Rejecting pushed response from "%s": server is not authoritative for "%s"', $origin, $url)); 127 | 128 | return \CURL_PUSH_DENY; 129 | } 130 | 131 | if ($maxPendingPushes <= \count($this->pushedResponses)) { 132 | $fifoUrl = key($this->pushedResponses); 133 | unset($this->pushedResponses[$fifoUrl]); 134 | $this->logger?->debug(\sprintf('Evicting oldest pushed response: "%s"', $fifoUrl)); 135 | } 136 | 137 | $url .= $headers[':path'][0]; 138 | $this->logger?->debug(\sprintf('Queueing pushed response: "%s"', $url)); 139 | 140 | $this->pushedResponses[$url] = new PushedResponse(new CurlResponse($this, $pushed), $headers, $this->openHandles[(int) $parent][1] ?? [], $pushed); 141 | 142 | return \CURL_PUSH_OK; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /Internal/DnsCache.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Internal; 13 | 14 | /** 15 | * Cache for resolved DNS queries. 16 | * 17 | * @author Alexander M. Turek 18 | * 19 | * @internal 20 | */ 21 | final class DnsCache 22 | { 23 | /** 24 | * Resolved hostnames (hostname => IP address). 25 | * 26 | * @var string[] 27 | */ 28 | public array $hostnames = []; 29 | 30 | /** 31 | * @var string[] 32 | */ 33 | public array $removals = []; 34 | 35 | /** 36 | * @var string[] 37 | */ 38 | public array $evictions = []; 39 | } 40 | -------------------------------------------------------------------------------- /Internal/HttplugWaitLoop.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Internal; 13 | 14 | use Http\Client\Exception\NetworkException; 15 | use Http\Promise\Promise; 16 | use Psr\Http\Message\RequestInterface as Psr7RequestInterface; 17 | use Psr\Http\Message\ResponseFactoryInterface; 18 | use Psr\Http\Message\ResponseInterface as Psr7ResponseInterface; 19 | use Psr\Http\Message\StreamFactoryInterface; 20 | use Symfony\Component\HttpClient\Response\StreamableInterface; 21 | use Symfony\Component\HttpClient\Response\StreamWrapper; 22 | use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; 23 | use Symfony\Contracts\HttpClient\HttpClientInterface; 24 | use Symfony\Contracts\HttpClient\ResponseInterface; 25 | 26 | /** 27 | * @author Nicolas Grekas 28 | * 29 | * @internal 30 | */ 31 | final class HttplugWaitLoop 32 | { 33 | /** 34 | * @param \SplObjectStorage|null $promisePool 35 | */ 36 | public function __construct( 37 | private HttpClientInterface $client, 38 | private ?\SplObjectStorage $promisePool, 39 | private ResponseFactoryInterface $responseFactory, 40 | private StreamFactoryInterface $streamFactory, 41 | ) { 42 | } 43 | 44 | public function wait(?ResponseInterface $pendingResponse, ?float $maxDuration = null, ?float $idleTimeout = null): int 45 | { 46 | if (!$this->promisePool) { 47 | return 0; 48 | } 49 | 50 | $guzzleQueue = \GuzzleHttp\Promise\Utils::queue(); 51 | 52 | if (0.0 === $remainingDuration = $maxDuration) { 53 | $idleTimeout = 0.0; 54 | } elseif (null !== $maxDuration) { 55 | $startTime = hrtime(true) / 1E9; 56 | $idleTimeout = max(0.0, min($maxDuration / 5, $idleTimeout ?? $maxDuration)); 57 | } 58 | 59 | do { 60 | foreach ($this->client->stream($this->promisePool, $idleTimeout) as $response => $chunk) { 61 | try { 62 | if (null !== $maxDuration && $chunk->isTimeout()) { 63 | goto check_duration; 64 | } 65 | 66 | if ($chunk->isFirst()) { 67 | // Deactivate throwing on 3/4/5xx 68 | $response->getStatusCode(); 69 | } 70 | 71 | if (!$chunk->isLast()) { 72 | goto check_duration; 73 | } 74 | 75 | if ([, $promise] = $this->promisePool[$response] ?? null) { 76 | unset($this->promisePool[$response]); 77 | $promise->resolve(self::createPsr7Response($this->responseFactory, $this->streamFactory, $this->client, $response, true)); 78 | } 79 | } catch (\Exception $e) { 80 | if ([$request, $promise] = $this->promisePool[$response] ?? null) { 81 | unset($this->promisePool[$response]); 82 | 83 | if ($e instanceof TransportExceptionInterface) { 84 | $e = new NetworkException($e->getMessage(), $request, $e); 85 | } 86 | 87 | $promise->reject($e); 88 | } 89 | } 90 | 91 | $guzzleQueue->run(); 92 | 93 | if ($pendingResponse === $response) { 94 | return $this->promisePool->count(); 95 | } 96 | 97 | check_duration: 98 | if (null !== $maxDuration && $idleTimeout && $idleTimeout > $remainingDuration = max(0.0, $maxDuration - hrtime(true) / 1E9 + $startTime)) { 99 | $idleTimeout = $remainingDuration / 5; 100 | break; 101 | } 102 | } 103 | 104 | if (!$count = $this->promisePool->count()) { 105 | return 0; 106 | } 107 | } while (null === $maxDuration || 0 < $remainingDuration); 108 | 109 | return $count; 110 | } 111 | 112 | public static function createPsr7Response(ResponseFactoryInterface $responseFactory, StreamFactoryInterface $streamFactory, HttpClientInterface $client, ResponseInterface $response, bool $buffer): Psr7ResponseInterface 113 | { 114 | $responseParameters = [$response->getStatusCode()]; 115 | 116 | foreach ($response->getInfo('response_headers') as $h) { 117 | if (11 <= \strlen($h) && '/' === $h[4] && preg_match('#^HTTP/\d+(?:\.\d+)? (?:\d\d\d) (.+)#', $h, $m)) { 118 | $responseParameters[1] = $m[1]; 119 | } 120 | } 121 | 122 | $psrResponse = $responseFactory->createResponse(...$responseParameters); 123 | 124 | foreach ($response->getHeaders(false) as $name => $values) { 125 | foreach ($values as $value) { 126 | try { 127 | $psrResponse = $psrResponse->withAddedHeader($name, $value); 128 | } catch (\InvalidArgumentException $e) { 129 | // ignore invalid header 130 | } 131 | } 132 | } 133 | 134 | if ($response instanceof StreamableInterface) { 135 | $body = $streamFactory->createStreamFromResource($response->toStream(false)); 136 | } elseif (!$buffer) { 137 | $body = $streamFactory->createStreamFromResource(StreamWrapper::createResource($response, $client)); 138 | } else { 139 | $body = $streamFactory->createStream($response->getContent(false)); 140 | } 141 | 142 | if ($body->isSeekable()) { 143 | try { 144 | $body->seek(0); 145 | } catch (\RuntimeException) { 146 | // ignore 147 | } 148 | } 149 | 150 | return $psrResponse->withBody($body); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /Internal/NativeClientState.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Internal; 13 | 14 | /** 15 | * Internal representation of the native client's state. 16 | * 17 | * @author Alexander M. Turek 18 | * 19 | * @internal 20 | */ 21 | final class NativeClientState extends ClientState 22 | { 23 | public int $id; 24 | public int $maxHostConnections = \PHP_INT_MAX; 25 | public int $responseCount = 0; 26 | /** @var string[] */ 27 | public array $dnsCache = []; 28 | public bool $sleep = false; 29 | /** @var int[] */ 30 | public array $hosts = []; 31 | 32 | public function __construct() 33 | { 34 | $this->id = random_int(\PHP_INT_MIN, \PHP_INT_MAX); 35 | } 36 | 37 | public function reset(): void 38 | { 39 | $this->responseCount = 0; 40 | $this->dnsCache = []; 41 | $this->hosts = []; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Internal/PushedResponse.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Internal; 13 | 14 | use Symfony\Component\HttpClient\Response\CurlResponse; 15 | 16 | /** 17 | * A pushed response with its request headers. 18 | * 19 | * @author Alexander M. Turek 20 | * 21 | * @internal 22 | */ 23 | final class PushedResponse 24 | { 25 | public function __construct( 26 | public CurlResponse $response, 27 | public array $requestHeaders, 28 | public array $parentOptions, 29 | public \CurlHandle $handle, 30 | ) { 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Messenger/PingWebhookMessage.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Messenger; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class PingWebhookMessage implements \Stringable 18 | { 19 | public function __construct( 20 | public readonly string $method, 21 | public readonly string $url, 22 | public readonly array $options = [], 23 | public readonly bool $throw = true, 24 | ) { 25 | } 26 | 27 | public function __toString(): string 28 | { 29 | return "[{$this->method}] {$this->url}"; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Messenger/PingWebhookMessageHandler.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Messenger; 13 | 14 | use Symfony\Contracts\HttpClient\HttpClientInterface; 15 | use Symfony\Contracts\HttpClient\ResponseInterface; 16 | 17 | /** 18 | * @author Kevin Bond 19 | */ 20 | class PingWebhookMessageHandler 21 | { 22 | public function __construct( 23 | private readonly HttpClientInterface $httpClient, 24 | ) { 25 | } 26 | 27 | public function __invoke(PingWebhookMessage $message): ResponseInterface 28 | { 29 | $response = $this->httpClient->request($message->method, $message->url, $message->options); 30 | $response->getHeaders($message->throw); 31 | 32 | return $response; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /MockHttpClient.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient; 13 | 14 | use Symfony\Component\HttpClient\Exception\TransportException; 15 | use Symfony\Component\HttpClient\Response\MockResponse; 16 | use Symfony\Component\HttpClient\Response\ResponseStream; 17 | use Symfony\Contracts\HttpClient\HttpClientInterface; 18 | use Symfony\Contracts\HttpClient\ResponseInterface; 19 | use Symfony\Contracts\HttpClient\ResponseStreamInterface; 20 | use Symfony\Contracts\Service\ResetInterface; 21 | 22 | /** 23 | * A test-friendly HttpClient that doesn't make actual HTTP requests. 24 | * 25 | * @author Nicolas Grekas 26 | */ 27 | class MockHttpClient implements HttpClientInterface, ResetInterface 28 | { 29 | use HttpClientTrait; 30 | 31 | private ResponseInterface|\Closure|iterable|null $responseFactory; 32 | private int $requestsCount = 0; 33 | private array $defaultOptions = []; 34 | 35 | /** 36 | * @param callable|callable[]|ResponseInterface|ResponseInterface[]|iterable|null $responseFactory 37 | */ 38 | public function __construct(callable|iterable|ResponseInterface|null $responseFactory = null, ?string $baseUri = 'https://example.com') 39 | { 40 | $this->setResponseFactory($responseFactory); 41 | $this->defaultOptions['base_uri'] = $baseUri; 42 | } 43 | 44 | /** 45 | * @param callable|callable[]|ResponseInterface|ResponseInterface[]|iterable|null $responseFactory 46 | */ 47 | public function setResponseFactory($responseFactory): void 48 | { 49 | if ($responseFactory instanceof ResponseInterface) { 50 | $responseFactory = [$responseFactory]; 51 | } 52 | 53 | if (!$responseFactory instanceof \Iterator && null !== $responseFactory && !\is_callable($responseFactory)) { 54 | $responseFactory = (static function () use ($responseFactory) { 55 | yield from $responseFactory; 56 | })(); 57 | } 58 | 59 | $this->responseFactory = !\is_callable($responseFactory) ? $responseFactory : $responseFactory(...); 60 | } 61 | 62 | public function request(string $method, string $url, array $options = []): ResponseInterface 63 | { 64 | [$url, $options] = $this->prepareRequest($method, $url, $options, $this->defaultOptions, true); 65 | $url = implode('', $url); 66 | 67 | if (null === $this->responseFactory) { 68 | $response = new MockResponse(); 69 | } elseif (\is_callable($this->responseFactory)) { 70 | $response = ($this->responseFactory)($method, $url, $options); 71 | } elseif (!$this->responseFactory->valid()) { 72 | throw new TransportException($this->requestsCount ? 'No more response left in the response factory iterator passed to MockHttpClient: the number of requests exceeds the number of responses.' : 'The response factory iterator passed to MockHttpClient is empty.'); 73 | } else { 74 | $responseFactory = $this->responseFactory->current(); 75 | $response = \is_callable($responseFactory) ? $responseFactory($method, $url, $options) : $responseFactory; 76 | $this->responseFactory->next(); 77 | } 78 | ++$this->requestsCount; 79 | 80 | if (!$response instanceof ResponseInterface) { 81 | throw new TransportException(\sprintf('The response factory passed to MockHttpClient must return/yield an instance of ResponseInterface, "%s" given.', get_debug_type($response))); 82 | } 83 | 84 | return MockResponse::fromRequest($method, $url, $options, $response); 85 | } 86 | 87 | public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface 88 | { 89 | if ($responses instanceof ResponseInterface) { 90 | $responses = [$responses]; 91 | } 92 | 93 | return new ResponseStream(MockResponse::stream($responses, $timeout)); 94 | } 95 | 96 | public function getRequestsCount(): int 97 | { 98 | return $this->requestsCount; 99 | } 100 | 101 | public function withOptions(array $options): static 102 | { 103 | $clone = clone $this; 104 | $clone->defaultOptions = self::mergeDefaultOptions($options, $this->defaultOptions, true); 105 | 106 | return $clone; 107 | } 108 | 109 | public function reset(): void 110 | { 111 | $this->requestsCount = 0; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Psr18Client.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient; 13 | 14 | use Http\Discovery\Psr17Factory; 15 | use Http\Discovery\Psr17FactoryDiscovery; 16 | use Nyholm\Psr7\Factory\Psr17Factory as NyholmPsr17Factory; 17 | use Nyholm\Psr7\Request; 18 | use Nyholm\Psr7\Uri; 19 | use Psr\Http\Client\ClientInterface; 20 | use Psr\Http\Client\NetworkExceptionInterface; 21 | use Psr\Http\Client\RequestExceptionInterface; 22 | use Psr\Http\Message\RequestFactoryInterface; 23 | use Psr\Http\Message\RequestInterface; 24 | use Psr\Http\Message\ResponseFactoryInterface; 25 | use Psr\Http\Message\ResponseInterface; 26 | use Psr\Http\Message\StreamFactoryInterface; 27 | use Psr\Http\Message\StreamInterface; 28 | use Psr\Http\Message\UriFactoryInterface; 29 | use Psr\Http\Message\UriInterface; 30 | use Symfony\Component\HttpClient\Internal\HttplugWaitLoop; 31 | use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; 32 | use Symfony\Contracts\HttpClient\HttpClientInterface; 33 | use Symfony\Contracts\Service\ResetInterface; 34 | 35 | if (!interface_exists(ClientInterface::class)) { 36 | throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\Psr18Client" as the "psr/http-client" package is not installed. Try running "composer require php-http/discovery psr/http-client-implementation:*".'); 37 | } 38 | 39 | if (!interface_exists(RequestFactoryInterface::class)) { 40 | throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\Psr18Client" as the "psr/http-factory" package is not installed. Try running "composer require php-http/discovery psr/http-factory-implementation:*".'); 41 | } 42 | 43 | /** 44 | * An adapter to turn a Symfony HttpClientInterface into a PSR-18 ClientInterface. 45 | * 46 | * Run "composer require php-http/discovery psr/http-client-implementation:*" 47 | * to get the required dependencies. 48 | * 49 | * @author Nicolas Grekas 50 | */ 51 | final class Psr18Client implements ClientInterface, RequestFactoryInterface, StreamFactoryInterface, UriFactoryInterface, ResetInterface 52 | { 53 | private HttpClientInterface $client; 54 | private ResponseFactoryInterface $responseFactory; 55 | private StreamFactoryInterface $streamFactory; 56 | 57 | public function __construct(?HttpClientInterface $client = null, ?ResponseFactoryInterface $responseFactory = null, ?StreamFactoryInterface $streamFactory = null) 58 | { 59 | $this->client = $client ?? HttpClient::create(); 60 | $streamFactory ??= $responseFactory instanceof StreamFactoryInterface ? $responseFactory : null; 61 | 62 | if (null === $responseFactory || null === $streamFactory) { 63 | if (class_exists(Psr17Factory::class)) { 64 | $psr17Factory = new Psr17Factory(); 65 | } elseif (class_exists(NyholmPsr17Factory::class)) { 66 | $psr17Factory = new NyholmPsr17Factory(); 67 | } else { 68 | throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\Psr18Client" as no PSR-17 factories have been provided. Try running "composer require php-http/discovery psr/http-factory-implementation:*".'); 69 | } 70 | 71 | $responseFactory ??= $psr17Factory; 72 | $streamFactory ??= $psr17Factory; 73 | } 74 | 75 | $this->responseFactory = $responseFactory; 76 | $this->streamFactory = $streamFactory; 77 | } 78 | 79 | public function withOptions(array $options): static 80 | { 81 | $clone = clone $this; 82 | $clone->client = $clone->client->withOptions($options); 83 | 84 | return $clone; 85 | } 86 | 87 | public function sendRequest(RequestInterface $request): ResponseInterface 88 | { 89 | try { 90 | $body = $request->getBody(); 91 | $headers = $request->getHeaders(); 92 | 93 | $size = $request->getHeader('content-length')[0] ?? -1; 94 | if (0 > $size && 0 < $size = $body->getSize() ?? -1) { 95 | $headers['Content-Length'] = [$size]; 96 | } 97 | 98 | if (0 === $size) { 99 | $body = ''; 100 | } elseif (0 < $size && $size < 1 << 21) { 101 | if ($body->isSeekable()) { 102 | try { 103 | $body->seek(0); 104 | } catch (\RuntimeException) { 105 | // ignore 106 | } 107 | } 108 | 109 | $body = $body->getContents(); 110 | } else { 111 | $body = static function (int $size) use ($body) { 112 | if ($body->isSeekable()) { 113 | try { 114 | $body->seek(0); 115 | } catch (\RuntimeException) { 116 | // ignore 117 | } 118 | } 119 | 120 | while (!$body->eof()) { 121 | yield $body->read($size); 122 | } 123 | }; 124 | } 125 | 126 | $options = [ 127 | 'headers' => $headers, 128 | 'body' => $body, 129 | ]; 130 | 131 | if ('1.0' === $request->getProtocolVersion()) { 132 | $options['http_version'] = '1.0'; 133 | } 134 | 135 | $response = $this->client->request($request->getMethod(), (string) $request->getUri(), $options); 136 | 137 | return HttplugWaitLoop::createPsr7Response($this->responseFactory, $this->streamFactory, $this->client, $response, false); 138 | } catch (TransportExceptionInterface $e) { 139 | if ($e instanceof \InvalidArgumentException) { 140 | throw new Psr18RequestException($e, $request); 141 | } 142 | 143 | throw new Psr18NetworkException($e, $request); 144 | } 145 | } 146 | 147 | public function createRequest(string $method, $uri): RequestInterface 148 | { 149 | if ($this->responseFactory instanceof RequestFactoryInterface) { 150 | return $this->responseFactory->createRequest($method, $uri); 151 | } 152 | 153 | if (class_exists(Psr17FactoryDiscovery::class)) { 154 | return Psr17FactoryDiscovery::findRequestFactory()->createRequest($method, $uri); 155 | } 156 | 157 | if (class_exists(Request::class)) { 158 | return new Request($method, $uri); 159 | } 160 | 161 | throw new \LogicException(\sprintf('You cannot use "%s()" as no PSR-17 factories have been found. Try running "composer require php-http/discovery psr/http-factory-implementation:*".', __METHOD__)); 162 | } 163 | 164 | public function createStream(string $content = ''): StreamInterface 165 | { 166 | $stream = $this->streamFactory->createStream($content); 167 | 168 | if ($stream->isSeekable()) { 169 | try { 170 | $stream->seek(0); 171 | } catch (\RuntimeException) { 172 | // ignore 173 | } 174 | } 175 | 176 | return $stream; 177 | } 178 | 179 | public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface 180 | { 181 | return $this->streamFactory->createStreamFromFile($filename, $mode); 182 | } 183 | 184 | public function createStreamFromResource($resource): StreamInterface 185 | { 186 | return $this->streamFactory->createStreamFromResource($resource); 187 | } 188 | 189 | public function createUri(string $uri = ''): UriInterface 190 | { 191 | if ($this->responseFactory instanceof UriFactoryInterface) { 192 | return $this->responseFactory->createUri($uri); 193 | } 194 | 195 | if (class_exists(Psr17FactoryDiscovery::class)) { 196 | return Psr17FactoryDiscovery::findUrlFactory()->createUri($uri); 197 | } 198 | 199 | if (class_exists(Uri::class)) { 200 | return new Uri($uri); 201 | } 202 | 203 | throw new \LogicException(\sprintf('You cannot use "%s()" as no PSR-17 factories have been found. Try running "composer require php-http/discovery psr/http-factory-implementation:*".', __METHOD__)); 204 | } 205 | 206 | public function reset(): void 207 | { 208 | if ($this->client instanceof ResetInterface) { 209 | $this->client->reset(); 210 | } 211 | } 212 | } 213 | 214 | /** 215 | * @internal 216 | */ 217 | class Psr18NetworkException extends \RuntimeException implements NetworkExceptionInterface 218 | { 219 | public function __construct( 220 | TransportExceptionInterface $e, 221 | private RequestInterface $request, 222 | ) { 223 | parent::__construct($e->getMessage(), 0, $e); 224 | } 225 | 226 | public function getRequest(): RequestInterface 227 | { 228 | return $this->request; 229 | } 230 | } 231 | 232 | /** 233 | * @internal 234 | */ 235 | class Psr18RequestException extends \InvalidArgumentException implements RequestExceptionInterface 236 | { 237 | public function __construct( 238 | TransportExceptionInterface $e, 239 | private RequestInterface $request, 240 | ) { 241 | parent::__construct($e->getMessage(), 0, $e); 242 | } 243 | 244 | public function getRequest(): RequestInterface 245 | { 246 | return $this->request; 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | HttpClient component 2 | ==================== 3 | 4 | The HttpClient component provides powerful methods to fetch HTTP resources synchronously or asynchronously. 5 | 6 | Resources 7 | --------- 8 | 9 | * [Documentation](https://symfony.com/doc/current/components/http_client.html) 10 | * [Contributing](https://symfony.com/doc/current/contributing/index.html) 11 | * [Report issues](https://github.com/symfony/symfony/issues) and 12 | [send Pull Requests](https://github.com/symfony/symfony/pulls) 13 | in the [main Symfony repository](https://github.com/symfony/symfony) 14 | -------------------------------------------------------------------------------- /Response/AsyncContext.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Response; 13 | 14 | use Symfony\Component\HttpClient\Chunk\DataChunk; 15 | use Symfony\Component\HttpClient\Chunk\LastChunk; 16 | use Symfony\Component\HttpClient\Exception\TransportException; 17 | use Symfony\Contracts\HttpClient\ChunkInterface; 18 | use Symfony\Contracts\HttpClient\HttpClientInterface; 19 | use Symfony\Contracts\HttpClient\ResponseInterface; 20 | 21 | /** 22 | * A DTO to work with AsyncResponse. 23 | * 24 | * @author Nicolas Grekas 25 | */ 26 | final class AsyncContext 27 | { 28 | /** @var callable|null */ 29 | private $passthru; 30 | private ResponseInterface $response; 31 | private array $info = []; 32 | 33 | /** 34 | * @param resource|null $content 35 | */ 36 | public function __construct( 37 | ?callable &$passthru, 38 | private HttpClientInterface $client, 39 | ResponseInterface &$response, 40 | array &$info, 41 | private $content, 42 | private int $offset, 43 | ) { 44 | $this->passthru = &$passthru; 45 | $this->response = &$response; 46 | $this->info = &$info; 47 | } 48 | 49 | /** 50 | * Returns the HTTP status without consuming the response. 51 | */ 52 | public function getStatusCode(): int 53 | { 54 | return $this->response->getInfo('http_code'); 55 | } 56 | 57 | /** 58 | * Returns the headers without consuming the response. 59 | */ 60 | public function getHeaders(): array 61 | { 62 | $headers = []; 63 | 64 | foreach ($this->response->getInfo('response_headers') as $h) { 65 | if (11 <= \strlen($h) && '/' === $h[4] && preg_match('#^HTTP/\d+(?:\.\d+)? ([123456789]\d\d)(?: |$)#', $h, $m)) { 66 | $headers = []; 67 | } elseif (2 === \count($m = explode(':', $h, 2))) { 68 | $headers[strtolower($m[0])][] = ltrim($m[1]); 69 | } 70 | } 71 | 72 | return $headers; 73 | } 74 | 75 | /** 76 | * @return resource|null The PHP stream resource where the content is buffered, if it is 77 | */ 78 | public function getContent() 79 | { 80 | return $this->content; 81 | } 82 | 83 | /** 84 | * Creates a new chunk of content. 85 | */ 86 | public function createChunk(string $data): ChunkInterface 87 | { 88 | return new DataChunk($this->offset, $data); 89 | } 90 | 91 | /** 92 | * Pauses the request for the given number of seconds. 93 | */ 94 | public function pause(float $duration): void 95 | { 96 | if (\is_callable($pause = $this->response->getInfo('pause_handler'))) { 97 | $pause($duration); 98 | } elseif (0 < $duration) { 99 | usleep((int) (1E6 * $duration)); 100 | } 101 | } 102 | 103 | /** 104 | * Cancels the request and returns the last chunk to yield. 105 | */ 106 | public function cancel(): ChunkInterface 107 | { 108 | $this->info['canceled'] = true; 109 | $this->info['error'] = 'Response has been canceled.'; 110 | $this->response->cancel(); 111 | 112 | return new LastChunk(); 113 | } 114 | 115 | /** 116 | * Returns the current info of the response. 117 | */ 118 | public function getInfo(?string $type = null): mixed 119 | { 120 | if (null !== $type) { 121 | return $this->info[$type] ?? $this->response->getInfo($type); 122 | } 123 | 124 | return $this->info + $this->response->getInfo(); 125 | } 126 | 127 | /** 128 | * Attaches an info to the response. 129 | * 130 | * @return $this 131 | */ 132 | public function setInfo(string $type, mixed $value): static 133 | { 134 | if ('canceled' === $type && $value !== $this->info['canceled']) { 135 | throw new \LogicException('You cannot set the "canceled" info directly.'); 136 | } 137 | 138 | if (null === $value) { 139 | unset($this->info[$type]); 140 | } else { 141 | $this->info[$type] = $value; 142 | } 143 | 144 | return $this; 145 | } 146 | 147 | /** 148 | * Returns the currently processed response. 149 | */ 150 | public function getResponse(): ResponseInterface 151 | { 152 | return $this->response; 153 | } 154 | 155 | /** 156 | * Replaces the currently processed response by doing a new request. 157 | */ 158 | public function replaceRequest(string $method, string $url, array $options = []): ResponseInterface 159 | { 160 | $this->info['previous_info'][] = $info = $this->response->getInfo(); 161 | if (null !== $onProgress = $options['on_progress'] ?? null) { 162 | $thisInfo = &$this->info; 163 | $options['on_progress'] = static function (int $dlNow, int $dlSize, array $info) use (&$thisInfo, $onProgress) { 164 | $onProgress($dlNow, $dlSize, $thisInfo + $info); 165 | }; 166 | } 167 | if (0 < ($info['max_duration'] ?? 0) && 0 < ($info['total_time'] ?? 0)) { 168 | if (0 >= $options['max_duration'] = $info['max_duration'] - $info['total_time']) { 169 | throw new TransportException(\sprintf('Max duration was reached for "%s".', $info['url'])); 170 | } 171 | } 172 | 173 | return $this->response = $this->client->request($method, $url, ['buffer' => false] + $options); 174 | } 175 | 176 | /** 177 | * Replaces the currently processed response by another one. 178 | */ 179 | public function replaceResponse(ResponseInterface $response): ResponseInterface 180 | { 181 | $this->info['previous_info'][] = $this->response->getInfo(); 182 | 183 | return $this->response = $response; 184 | } 185 | 186 | /** 187 | * Replaces or removes the chunk filter iterator. 188 | * 189 | * @param ?callable(ChunkInterface, self): ?\Iterator $passthru 190 | */ 191 | public function passthru(?callable $passthru = null): void 192 | { 193 | $this->passthru = $passthru ?? static function ($chunk, $context) { 194 | $context->passthru = null; 195 | 196 | yield $chunk; 197 | }; 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /Response/CommonResponseTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Response; 13 | 14 | use Symfony\Component\HttpClient\Exception\ClientException; 15 | use Symfony\Component\HttpClient\Exception\JsonException; 16 | use Symfony\Component\HttpClient\Exception\RedirectionException; 17 | use Symfony\Component\HttpClient\Exception\ServerException; 18 | use Symfony\Component\HttpClient\Exception\TransportException; 19 | 20 | /** 21 | * Implements common logic for response classes. 22 | * 23 | * @author Nicolas Grekas 24 | * 25 | * @internal 26 | */ 27 | trait CommonResponseTrait 28 | { 29 | /** 30 | * @var callable|null A callback that tells whether we're waiting for response headers 31 | */ 32 | private $initializer; 33 | /** @var bool|\Closure|resource|null */ 34 | private $shouldBuffer; 35 | /** @var resource|null */ 36 | private $content; 37 | private int $offset = 0; 38 | private ?array $jsonData = null; 39 | 40 | public function getContent(bool $throw = true): string 41 | { 42 | if ($this->initializer) { 43 | self::initialize($this); 44 | } 45 | 46 | if ($throw) { 47 | $this->checkStatusCode(); 48 | } 49 | 50 | if (null === $this->content) { 51 | $content = null; 52 | 53 | foreach (self::stream([$this]) as $chunk) { 54 | if (!$chunk->isLast()) { 55 | $content .= $chunk->getContent(); 56 | } 57 | } 58 | 59 | if (null !== $content) { 60 | return $content; 61 | } 62 | 63 | if (null === $this->content) { 64 | throw new TransportException('Cannot get the content of the response twice: buffering is disabled.'); 65 | } 66 | } else { 67 | foreach (self::stream([$this]) as $chunk) { 68 | // Chunks are buffered in $this->content already 69 | } 70 | } 71 | 72 | rewind($this->content); 73 | 74 | return stream_get_contents($this->content); 75 | } 76 | 77 | public function toArray(bool $throw = true): array 78 | { 79 | if ('' === $content = $this->getContent($throw)) { 80 | throw new JsonException('Response body is empty.'); 81 | } 82 | 83 | if (null !== $this->jsonData) { 84 | return $this->jsonData; 85 | } 86 | 87 | try { 88 | $content = json_decode($content, true, 512, \JSON_BIGINT_AS_STRING | \JSON_THROW_ON_ERROR); 89 | } catch (\JsonException $e) { 90 | throw new JsonException($e->getMessage().\sprintf(' for "%s".', $this->getInfo('url')), $e->getCode()); 91 | } 92 | 93 | if (!\is_array($content)) { 94 | throw new JsonException(\sprintf('JSON content was expected to decode to an array, "%s" returned for "%s".', get_debug_type($content), $this->getInfo('url'))); 95 | } 96 | 97 | if (null !== $this->content) { 98 | // Option "buffer" is true 99 | return $this->jsonData = $content; 100 | } 101 | 102 | return $content; 103 | } 104 | 105 | /** 106 | * @return resource 107 | */ 108 | public function toStream(bool $throw = true) 109 | { 110 | if ($throw) { 111 | // Ensure headers arrived 112 | $this->getHeaders($throw); 113 | } 114 | 115 | $stream = StreamWrapper::createResource($this); 116 | stream_get_meta_data($stream)['wrapper_data'] 117 | ->bindHandles($this->handle, $this->content); 118 | 119 | return $stream; 120 | } 121 | 122 | public function __sleep(): array 123 | { 124 | throw new \BadMethodCallException('Cannot serialize '.__CLASS__); 125 | } 126 | 127 | public function __wakeup(): void 128 | { 129 | throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); 130 | } 131 | 132 | /** 133 | * Closes the response and all its network handles. 134 | */ 135 | abstract protected function close(): void; 136 | 137 | private static function initialize(self $response): void 138 | { 139 | if (null !== $response->getInfo('error')) { 140 | throw new TransportException($response->getInfo('error')); 141 | } 142 | 143 | try { 144 | if (($response->initializer)($response, -0.0)) { 145 | foreach (self::stream([$response], -0.0) as $chunk) { 146 | if ($chunk->isFirst()) { 147 | break; 148 | } 149 | } 150 | } 151 | } catch (\Throwable $e) { 152 | // Persist timeouts thrown during initialization 153 | $response->info['error'] = $e->getMessage(); 154 | $response->close(); 155 | throw $e; 156 | } 157 | 158 | $response->initializer = null; 159 | } 160 | 161 | private function checkStatusCode(): void 162 | { 163 | $code = $this->getInfo('http_code'); 164 | 165 | if (500 <= $code) { 166 | throw new ServerException($this); 167 | } 168 | 169 | if (400 <= $code) { 170 | throw new ClientException($this); 171 | } 172 | 173 | if (300 <= $code) { 174 | throw new RedirectionException($this); 175 | } 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /Response/HttplugPromise.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Response; 13 | 14 | use GuzzleHttp\Promise\Create; 15 | use GuzzleHttp\Promise\PromiseInterface as GuzzlePromiseInterface; 16 | use Http\Promise\Promise as HttplugPromiseInterface; 17 | use Psr\Http\Message\ResponseInterface as Psr7ResponseInterface; 18 | 19 | /** 20 | * @author Tobias Nyholm 21 | * 22 | * @internal 23 | */ 24 | final class HttplugPromise implements HttplugPromiseInterface 25 | { 26 | public function __construct( 27 | private GuzzlePromiseInterface $promise, 28 | ) { 29 | } 30 | 31 | public function then(?callable $onFulfilled = null, ?callable $onRejected = null): self 32 | { 33 | return new self($this->promise->then( 34 | $this->wrapThenCallback($onFulfilled), 35 | $this->wrapThenCallback($onRejected) 36 | )); 37 | } 38 | 39 | public function cancel(): void 40 | { 41 | $this->promise->cancel(); 42 | } 43 | 44 | public function getState(): string 45 | { 46 | return $this->promise->getState(); 47 | } 48 | 49 | /** 50 | * @return Psr7ResponseInterface|mixed 51 | */ 52 | public function wait($unwrap = true): mixed 53 | { 54 | $result = $this->promise->wait($unwrap); 55 | 56 | while ($result instanceof HttplugPromiseInterface || $result instanceof GuzzlePromiseInterface) { 57 | $result = $result->wait($unwrap); 58 | } 59 | 60 | return $result; 61 | } 62 | 63 | private function wrapThenCallback(?callable $callback): ?callable 64 | { 65 | if (null === $callback) { 66 | return null; 67 | } 68 | 69 | return static fn ($value) => Create::promiseFor($callback($value)); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Response/JsonMockResponse.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Response; 13 | 14 | use Symfony\Component\HttpClient\Exception\InvalidArgumentException; 15 | 16 | class JsonMockResponse extends MockResponse 17 | { 18 | /** 19 | * @param mixed $body Any value that `json_encode()` can serialize 20 | */ 21 | public function __construct(mixed $body = [], array $info = []) 22 | { 23 | try { 24 | $json = json_encode($body, \JSON_THROW_ON_ERROR | \JSON_PRESERVE_ZERO_FRACTION); 25 | } catch (\JsonException $e) { 26 | throw new InvalidArgumentException('JSON encoding failed: '.$e->getMessage(), $e->getCode(), $e); 27 | } 28 | 29 | $info['response_headers']['content-type'] ??= 'application/json'; 30 | 31 | parent::__construct($json, $info); 32 | } 33 | 34 | public static function fromFile(string $path, array $info = []): static 35 | { 36 | if (!is_file($path)) { 37 | throw new InvalidArgumentException(\sprintf('File not found: "%s".', $path)); 38 | } 39 | 40 | $json = file_get_contents($path); 41 | if (!json_validate($json)) { 42 | throw new \InvalidArgumentException(\sprintf('File "%s" does not contain valid JSON.', $path)); 43 | } 44 | 45 | return new static(json_decode($json, true, flags: \JSON_THROW_ON_ERROR), $info); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Response/ResponseStream.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Response; 13 | 14 | use Symfony\Contracts\HttpClient\ChunkInterface; 15 | use Symfony\Contracts\HttpClient\ResponseInterface; 16 | use Symfony\Contracts\HttpClient\ResponseStreamInterface; 17 | 18 | /** 19 | * @author Nicolas Grekas 20 | */ 21 | final class ResponseStream implements ResponseStreamInterface 22 | { 23 | public function __construct( 24 | private \Generator $generator, 25 | ) { 26 | } 27 | 28 | public function key(): ResponseInterface 29 | { 30 | return $this->generator->key(); 31 | } 32 | 33 | public function current(): ChunkInterface 34 | { 35 | return $this->generator->current(); 36 | } 37 | 38 | public function next(): void 39 | { 40 | $this->generator->next(); 41 | } 42 | 43 | public function rewind(): void 44 | { 45 | $this->generator->rewind(); 46 | } 47 | 48 | public function valid(): bool 49 | { 50 | return $this->generator->valid(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Response/StreamWrapper.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Response; 13 | 14 | use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; 15 | use Symfony\Contracts\HttpClient\HttpClientInterface; 16 | use Symfony\Contracts\HttpClient\ResponseInterface; 17 | 18 | /** 19 | * Allows turning ResponseInterface instances to PHP streams. 20 | * 21 | * @author Nicolas Grekas 22 | */ 23 | class StreamWrapper 24 | { 25 | /** @var resource|null */ 26 | public $context; 27 | 28 | private HttpClientInterface|ResponseInterface $client; 29 | 30 | private ResponseInterface $response; 31 | 32 | /** @var resource|string|null */ 33 | private $content; 34 | 35 | /** @var resource|callable|null */ 36 | private $handle; 37 | 38 | private bool $blocking = true; 39 | private ?float $timeout = null; 40 | private bool $eof = false; 41 | private ?int $offset = 0; 42 | 43 | /** 44 | * Creates a PHP stream resource from a ResponseInterface. 45 | * 46 | * @return resource 47 | */ 48 | public static function createResource(ResponseInterface $response, ?HttpClientInterface $client = null) 49 | { 50 | if ($response instanceof StreamableInterface) { 51 | $stack = debug_backtrace(\DEBUG_BACKTRACE_PROVIDE_OBJECT | \DEBUG_BACKTRACE_IGNORE_ARGS, 2); 52 | 53 | if ($response !== ($stack[1]['object'] ?? null)) { 54 | return $response->toStream(false); 55 | } 56 | } 57 | 58 | if (null === $client && !method_exists($response, 'stream')) { 59 | throw new \InvalidArgumentException(\sprintf('Providing a client to "%s()" is required when the response doesn\'t have any "stream()" method.', __CLASS__)); 60 | } 61 | 62 | static $registered = false; 63 | 64 | if (!$registered = $registered || stream_wrapper_register(strtr(__CLASS__, '\\', '-'), __CLASS__)) { 65 | throw new \RuntimeException(error_get_last()['message'] ?? 'Registering the "symfony" stream wrapper failed.'); 66 | } 67 | 68 | $context = [ 69 | 'client' => $client ?? $response, 70 | 'response' => $response, 71 | ]; 72 | 73 | return fopen(strtr(__CLASS__, '\\', '-').'://'.$response->getInfo('url'), 'r', false, stream_context_create(['symfony' => $context])); 74 | } 75 | 76 | public function getResponse(): ResponseInterface 77 | { 78 | return $this->response; 79 | } 80 | 81 | /** 82 | * @param resource|callable|null $handle The resource handle that should be monitored when 83 | * stream_select() is used on the created stream 84 | * @param resource|null $content The seekable resource where the response body is buffered 85 | */ 86 | public function bindHandles(&$handle, &$content): void 87 | { 88 | $this->handle = &$handle; 89 | $this->content = &$content; 90 | $this->offset = null; 91 | } 92 | 93 | public function stream_open(string $path, string $mode, int $options): bool 94 | { 95 | if ('r' !== $mode) { 96 | if ($options & \STREAM_REPORT_ERRORS) { 97 | trigger_error(\sprintf('Invalid mode "%s": only "r" is supported.', $mode), \E_USER_WARNING); 98 | } 99 | 100 | return false; 101 | } 102 | 103 | $context = stream_context_get_options($this->context)['symfony'] ?? null; 104 | $this->client = $context['client'] ?? null; 105 | $this->response = $context['response'] ?? null; 106 | $this->context = null; 107 | 108 | if (null !== $this->client && null !== $this->response) { 109 | return true; 110 | } 111 | 112 | if ($options & \STREAM_REPORT_ERRORS) { 113 | trigger_error('Missing options "client" or "response" in "symfony" stream context.', \E_USER_WARNING); 114 | } 115 | 116 | return false; 117 | } 118 | 119 | public function stream_read(int $count): string|false 120 | { 121 | if (\is_resource($this->content)) { 122 | // Empty the internal activity list 123 | foreach ($this->client->stream([$this->response], 0) as $chunk) { 124 | try { 125 | if (!$chunk->isTimeout() && $chunk->isFirst()) { 126 | $this->response->getStatusCode(); // ignore 3/4/5xx 127 | } 128 | } catch (ExceptionInterface $e) { 129 | trigger_error($e->getMessage(), \E_USER_WARNING); 130 | 131 | return false; 132 | } 133 | } 134 | 135 | if (0 !== fseek($this->content, $this->offset ?? 0)) { 136 | return false; 137 | } 138 | 139 | if ('' !== $data = fread($this->content, $count)) { 140 | fseek($this->content, 0, \SEEK_END); 141 | $this->offset += \strlen($data); 142 | 143 | return $data; 144 | } 145 | } 146 | 147 | if (\is_string($this->content)) { 148 | if (\strlen($this->content) <= $count) { 149 | $data = $this->content; 150 | $this->content = null; 151 | } else { 152 | $data = substr($this->content, 0, $count); 153 | $this->content = substr($this->content, $count); 154 | } 155 | $this->offset += \strlen($data); 156 | 157 | return $data; 158 | } 159 | 160 | foreach ($this->client->stream([$this->response], $this->blocking ? $this->timeout : 0) as $chunk) { 161 | try { 162 | $this->eof = true; 163 | $this->eof = !$chunk->isTimeout(); 164 | 165 | if (!$this->eof && !$this->blocking) { 166 | return ''; 167 | } 168 | 169 | $this->eof = $chunk->isLast(); 170 | 171 | if ($chunk->isFirst()) { 172 | $this->response->getStatusCode(); // ignore 3/4/5xx 173 | } 174 | 175 | if ('' !== $data = $chunk->getContent()) { 176 | if (\strlen($data) > $count) { 177 | $this->content ??= substr($data, $count); 178 | $data = substr($data, 0, $count); 179 | } 180 | $this->offset += \strlen($data); 181 | 182 | return $data; 183 | } 184 | } catch (ExceptionInterface $e) { 185 | trigger_error($e->getMessage(), \E_USER_WARNING); 186 | 187 | return false; 188 | } 189 | } 190 | 191 | return ''; 192 | } 193 | 194 | public function stream_set_option(int $option, int $arg1, ?int $arg2): bool 195 | { 196 | if (\STREAM_OPTION_BLOCKING === $option) { 197 | $this->blocking = (bool) $arg1; 198 | } elseif (\STREAM_OPTION_READ_TIMEOUT === $option) { 199 | $this->timeout = $arg1 + $arg2 / 1e6; 200 | } else { 201 | return false; 202 | } 203 | 204 | return true; 205 | } 206 | 207 | public function stream_tell(): int 208 | { 209 | return $this->offset ?? 0; 210 | } 211 | 212 | public function stream_eof(): bool 213 | { 214 | return $this->eof && !\is_string($this->content); 215 | } 216 | 217 | public function stream_seek(int $offset, int $whence = \SEEK_SET): bool 218 | { 219 | if (null === $this->content && null === $this->offset) { 220 | $this->response->getStatusCode(); 221 | $this->offset = 0; 222 | } 223 | 224 | if (!\is_resource($this->content) || 0 !== fseek($this->content, 0, \SEEK_END)) { 225 | return false; 226 | } 227 | 228 | $size = ftell($this->content); 229 | 230 | if (\SEEK_CUR === $whence) { 231 | $offset += $this->offset ?? 0; 232 | } 233 | 234 | if (\SEEK_END === $whence || $size < $offset) { 235 | foreach ($this->client->stream([$this->response]) as $chunk) { 236 | try { 237 | if ($chunk->isFirst()) { 238 | $this->response->getStatusCode(); // ignore 3/4/5xx 239 | } 240 | 241 | // Chunks are buffered in $this->content already 242 | $size += \strlen($chunk->getContent()); 243 | 244 | if (\SEEK_END !== $whence && $offset <= $size) { 245 | break; 246 | } 247 | } catch (ExceptionInterface $e) { 248 | trigger_error($e->getMessage(), \E_USER_WARNING); 249 | 250 | return false; 251 | } 252 | } 253 | 254 | if (\SEEK_END === $whence) { 255 | $offset += $size; 256 | } 257 | } 258 | 259 | if (0 <= $offset && $offset <= $size) { 260 | $this->eof = false; 261 | $this->offset = $offset; 262 | 263 | return true; 264 | } 265 | 266 | return false; 267 | } 268 | 269 | /** 270 | * @return resource|false 271 | */ 272 | public function stream_cast(int $castAs) 273 | { 274 | if (\STREAM_CAST_FOR_SELECT === $castAs) { 275 | $this->response->getHeaders(false); 276 | 277 | return (\is_callable($this->handle) ? ($this->handle)() : $this->handle) ?? false; 278 | } 279 | 280 | return false; 281 | } 282 | 283 | public function stream_stat(): array 284 | { 285 | try { 286 | $headers = $this->response->getHeaders(false); 287 | } catch (ExceptionInterface $e) { 288 | trigger_error($e->getMessage(), \E_USER_WARNING); 289 | $headers = []; 290 | } 291 | 292 | return [ 293 | 'dev' => 0, 294 | 'ino' => 0, 295 | 'mode' => 33060, 296 | 'nlink' => 0, 297 | 'uid' => 0, 298 | 'gid' => 0, 299 | 'rdev' => 0, 300 | 'size' => (int) ($headers['content-length'][0] ?? -1), 301 | 'atime' => 0, 302 | 'mtime' => strtotime($headers['last-modified'][0] ?? '') ?: 0, 303 | 'ctime' => 0, 304 | 'blksize' => 0, 305 | 'blocks' => 0, 306 | ]; 307 | } 308 | 309 | private function __construct() 310 | { 311 | } 312 | } 313 | -------------------------------------------------------------------------------- /Response/StreamableInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Response; 13 | 14 | use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; 15 | use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; 16 | use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; 17 | use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; 18 | 19 | /** 20 | * @author Nicolas Grekas 21 | */ 22 | interface StreamableInterface 23 | { 24 | /** 25 | * Casts the response to a PHP stream resource. 26 | * 27 | * @return resource 28 | * 29 | * @throws TransportExceptionInterface When a network error occurs 30 | * @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached 31 | * @throws ClientExceptionInterface On a 4xx when $throw is true 32 | * @throws ServerExceptionInterface On a 5xx when $throw is true 33 | */ 34 | public function toStream(bool $throw = true); 35 | } 36 | -------------------------------------------------------------------------------- /Response/TraceableResponse.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Response; 13 | 14 | use Symfony\Component\HttpClient\Chunk\ErrorChunk; 15 | use Symfony\Component\HttpClient\Exception\ClientException; 16 | use Symfony\Component\HttpClient\Exception\RedirectionException; 17 | use Symfony\Component\HttpClient\Exception\ServerException; 18 | use Symfony\Component\HttpClient\TraceableHttpClient; 19 | use Symfony\Component\Stopwatch\StopwatchEvent; 20 | use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; 21 | use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; 22 | use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; 23 | use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; 24 | use Symfony\Contracts\HttpClient\HttpClientInterface; 25 | use Symfony\Contracts\HttpClient\ResponseInterface; 26 | 27 | /** 28 | * @author Nicolas Grekas 29 | * 30 | * @internal 31 | */ 32 | class TraceableResponse implements ResponseInterface, StreamableInterface 33 | { 34 | public function __construct( 35 | private HttpClientInterface $client, 36 | private ResponseInterface $response, 37 | private mixed &$content = false, 38 | private ?StopwatchEvent $event = null, 39 | ) { 40 | } 41 | 42 | public function __sleep(): array 43 | { 44 | throw new \BadMethodCallException('Cannot serialize '.__CLASS__); 45 | } 46 | 47 | public function __wakeup(): void 48 | { 49 | throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); 50 | } 51 | 52 | public function __destruct() 53 | { 54 | try { 55 | if (method_exists($this->response, '__destruct')) { 56 | $this->response->__destruct(); 57 | } 58 | } finally { 59 | if ($this->event?->isStarted()) { 60 | $this->event->stop(); 61 | } 62 | } 63 | } 64 | 65 | public function getStatusCode(): int 66 | { 67 | try { 68 | return $this->response->getStatusCode(); 69 | } finally { 70 | if ($this->event?->isStarted()) { 71 | $this->event->lap(); 72 | } 73 | } 74 | } 75 | 76 | public function getHeaders(bool $throw = true): array 77 | { 78 | try { 79 | return $this->response->getHeaders($throw); 80 | } finally { 81 | if ($this->event?->isStarted()) { 82 | $this->event->lap(); 83 | } 84 | } 85 | } 86 | 87 | public function getContent(bool $throw = true): string 88 | { 89 | try { 90 | if (false === $this->content) { 91 | return $this->response->getContent($throw); 92 | } 93 | 94 | return $this->content = $this->response->getContent(false); 95 | } finally { 96 | if ($this->event?->isStarted()) { 97 | $this->event->stop(); 98 | } 99 | if ($throw) { 100 | $this->checkStatusCode($this->response->getStatusCode()); 101 | } 102 | } 103 | } 104 | 105 | public function toArray(bool $throw = true): array 106 | { 107 | try { 108 | if (false === $this->content) { 109 | return $this->response->toArray($throw); 110 | } 111 | 112 | return $this->content = $this->response->toArray(false); 113 | } finally { 114 | if ($this->event?->isStarted()) { 115 | $this->event->stop(); 116 | } 117 | if ($throw) { 118 | $this->checkStatusCode($this->response->getStatusCode()); 119 | } 120 | } 121 | } 122 | 123 | public function cancel(): void 124 | { 125 | $this->response->cancel(); 126 | 127 | if ($this->event?->isStarted()) { 128 | $this->event->stop(); 129 | } 130 | } 131 | 132 | public function getInfo(?string $type = null): mixed 133 | { 134 | return $this->response->getInfo($type); 135 | } 136 | 137 | /** 138 | * Casts the response to a PHP stream resource. 139 | * 140 | * @return resource 141 | * 142 | * @throws TransportExceptionInterface When a network error occurs 143 | * @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached 144 | * @throws ClientExceptionInterface On a 4xx when $throw is true 145 | * @throws ServerExceptionInterface On a 5xx when $throw is true 146 | */ 147 | public function toStream(bool $throw = true) 148 | { 149 | if ($throw) { 150 | // Ensure headers arrived 151 | $this->response->getHeaders(true); 152 | } 153 | 154 | if ($this->response instanceof StreamableInterface) { 155 | return $this->response->toStream(false); 156 | } 157 | 158 | return StreamWrapper::createResource($this->response, $this->client); 159 | } 160 | 161 | /** 162 | * @internal 163 | */ 164 | public static function stream(HttpClientInterface $client, iterable $responses, ?float $timeout): \Generator 165 | { 166 | $wrappedResponses = []; 167 | $traceableMap = new \SplObjectStorage(); 168 | 169 | foreach ($responses as $r) { 170 | if (!$r instanceof self) { 171 | throw new \TypeError(\sprintf('"%s::stream()" expects parameter 1 to be an iterable of TraceableResponse objects, "%s" given.', TraceableHttpClient::class, get_debug_type($r))); 172 | } 173 | 174 | $traceableMap[$r->response] = $r; 175 | $wrappedResponses[] = $r->response; 176 | if ($r->event && !$r->event->isStarted()) { 177 | $r->event->start(); 178 | } 179 | } 180 | 181 | foreach ($client->stream($wrappedResponses, $timeout) as $r => $chunk) { 182 | if ($traceableMap[$r]->event && $traceableMap[$r]->event->isStarted()) { 183 | try { 184 | if ($chunk->isTimeout() || !$chunk->isLast()) { 185 | $traceableMap[$r]->event->lap(); 186 | } else { 187 | $traceableMap[$r]->event->stop(); 188 | } 189 | } catch (TransportExceptionInterface $e) { 190 | $traceableMap[$r]->event->stop(); 191 | if ($chunk instanceof ErrorChunk) { 192 | $chunk->didThrow(false); 193 | } else { 194 | $chunk = new ErrorChunk($chunk->getOffset(), $e); 195 | } 196 | } 197 | } 198 | yield $traceableMap[$r] => $chunk; 199 | } 200 | } 201 | 202 | private function checkStatusCode(int $code): void 203 | { 204 | if (500 <= $code) { 205 | throw new ServerException($this); 206 | } 207 | 208 | if (400 <= $code) { 209 | throw new ClientException($this); 210 | } 211 | 212 | if (300 <= $code) { 213 | throw new RedirectionException($this); 214 | } 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /Retry/GenericRetryStrategy.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Retry; 13 | 14 | use Symfony\Component\HttpClient\Exception\InvalidArgumentException; 15 | use Symfony\Component\HttpClient\Response\AsyncContext; 16 | use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; 17 | 18 | /** 19 | * Decides to retry the request when HTTP status codes belong to the given list of codes. 20 | * 21 | * @author Jérémy Derussé 22 | */ 23 | class GenericRetryStrategy implements RetryStrategyInterface 24 | { 25 | public const IDEMPOTENT_METHODS = ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE']; 26 | public const DEFAULT_RETRY_STATUS_CODES = [ 27 | 0 => self::IDEMPOTENT_METHODS, // for transport exceptions 28 | 423, 29 | 425, 30 | 429, 31 | 500 => self::IDEMPOTENT_METHODS, 32 | 502, 33 | 503, 34 | 504 => self::IDEMPOTENT_METHODS, 35 | 507 => self::IDEMPOTENT_METHODS, 36 | 510 => self::IDEMPOTENT_METHODS, 37 | ]; 38 | 39 | /** 40 | * @param array $statusCodes List of HTTP status codes that trigger a retry 41 | * @param int $delayMs Amount of time to delay (or the initial value when multiplier is used) 42 | * @param float $multiplier Multiplier to apply to the delay each time a retry occurs 43 | * @param int $maxDelayMs Maximum delay to allow (0 means no maximum) 44 | * @param float $jitter Probability of randomness int delay (0 = none, 1 = 100% random) 45 | */ 46 | public function __construct( 47 | private array $statusCodes = self::DEFAULT_RETRY_STATUS_CODES, 48 | private int $delayMs = 1000, 49 | private float $multiplier = 2.0, 50 | private int $maxDelayMs = 0, 51 | private float $jitter = 0.1, 52 | ) { 53 | if ($delayMs < 0) { 54 | throw new InvalidArgumentException(\sprintf('Delay must be greater than or equal to zero: "%s" given.', $delayMs)); 55 | } 56 | 57 | if ($multiplier < 1) { 58 | throw new InvalidArgumentException(\sprintf('Multiplier must be greater than or equal to one: "%s" given.', $multiplier)); 59 | } 60 | 61 | if ($maxDelayMs < 0) { 62 | throw new InvalidArgumentException(\sprintf('Max delay must be greater than or equal to zero: "%s" given.', $maxDelayMs)); 63 | } 64 | 65 | if ($jitter < 0 || $jitter > 1) { 66 | throw new InvalidArgumentException(\sprintf('Jitter must be between 0 and 1: "%s" given.', $jitter)); 67 | } 68 | } 69 | 70 | public function shouldRetry(AsyncContext $context, ?string $responseContent, ?TransportExceptionInterface $exception): ?bool 71 | { 72 | $statusCode = $context->getStatusCode(); 73 | if (\in_array($statusCode, $this->statusCodes, true)) { 74 | return true; 75 | } 76 | if (isset($this->statusCodes[$statusCode]) && \is_array($this->statusCodes[$statusCode])) { 77 | return \in_array($context->getInfo('http_method'), $this->statusCodes[$statusCode], true); 78 | } 79 | if (null === $exception) { 80 | return false; 81 | } 82 | 83 | if (\in_array(0, $this->statusCodes, true)) { 84 | return true; 85 | } 86 | if (isset($this->statusCodes[0]) && \is_array($this->statusCodes[0])) { 87 | return \in_array($context->getInfo('http_method'), $this->statusCodes[0], true); 88 | } 89 | 90 | return false; 91 | } 92 | 93 | public function getDelay(AsyncContext $context, ?string $responseContent, ?TransportExceptionInterface $exception): int 94 | { 95 | $delay = $this->delayMs * $this->multiplier ** $context->getInfo('retry_count'); 96 | 97 | if ($this->jitter > 0) { 98 | $randomness = (int) ($delay * $this->jitter); 99 | $delay += random_int(-$randomness, +$randomness); 100 | } 101 | 102 | if ($delay > $this->maxDelayMs && 0 !== $this->maxDelayMs) { 103 | return $this->maxDelayMs; 104 | } 105 | 106 | return (int) $delay; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Retry/RetryStrategyInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Retry; 13 | 14 | use Symfony\Component\HttpClient\Response\AsyncContext; 15 | use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; 16 | 17 | /** 18 | * @author Jérémy Derussé 19 | * @author Nicolas Grekas 20 | */ 21 | interface RetryStrategyInterface 22 | { 23 | /** 24 | * Returns whether the request should be retried. 25 | * 26 | * @param ?string $responseContent Null is passed when the body did not arrive yet 27 | * 28 | * @return bool|null Returns null to signal that the body is required to take a decision 29 | */ 30 | public function shouldRetry(AsyncContext $context, ?string $responseContent, ?TransportExceptionInterface $exception): ?bool; 31 | 32 | /** 33 | * Returns the time to wait in milliseconds. 34 | */ 35 | public function getDelay(AsyncContext $context, ?string $responseContent, ?TransportExceptionInterface $exception): int; 36 | } 37 | -------------------------------------------------------------------------------- /RetryableHttpClient.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient; 13 | 14 | use Psr\Log\LoggerInterface; 15 | use Symfony\Component\HttpClient\Response\AsyncContext; 16 | use Symfony\Component\HttpClient\Response\AsyncResponse; 17 | use Symfony\Component\HttpClient\Retry\GenericRetryStrategy; 18 | use Symfony\Component\HttpClient\Retry\RetryStrategyInterface; 19 | use Symfony\Contracts\HttpClient\ChunkInterface; 20 | use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; 21 | use Symfony\Contracts\HttpClient\HttpClientInterface; 22 | use Symfony\Contracts\HttpClient\ResponseInterface; 23 | use Symfony\Contracts\Service\ResetInterface; 24 | 25 | /** 26 | * Automatically retries failing HTTP requests. 27 | * 28 | * @author Jérémy Derussé 29 | */ 30 | class RetryableHttpClient implements HttpClientInterface, ResetInterface 31 | { 32 | use AsyncDecoratorTrait; 33 | 34 | private RetryStrategyInterface $strategy; 35 | private array $baseUris = []; 36 | 37 | /** 38 | * @param int $maxRetries The maximum number of times to retry 39 | */ 40 | public function __construct( 41 | HttpClientInterface $client, 42 | ?RetryStrategyInterface $strategy = null, 43 | private int $maxRetries = 3, 44 | private ?LoggerInterface $logger = null, 45 | ) { 46 | $this->client = $client; 47 | $this->strategy = $strategy ?? new GenericRetryStrategy(); 48 | } 49 | 50 | public function withOptions(array $options): static 51 | { 52 | if (\array_key_exists('base_uri', $options)) { 53 | if (\is_array($options['base_uri'])) { 54 | $this->baseUris = $options['base_uri']; 55 | unset($options['base_uri']); 56 | } else { 57 | $this->baseUris = []; 58 | } 59 | } 60 | 61 | $clone = clone $this; 62 | $clone->maxRetries = (int) ($options['max_retries'] ?? $this->maxRetries); 63 | unset($options['max_retries']); 64 | 65 | $clone->client = $this->client->withOptions($options); 66 | 67 | return $clone; 68 | } 69 | 70 | public function request(string $method, string $url, array $options = []): ResponseInterface 71 | { 72 | $baseUris = \array_key_exists('base_uri', $options) ? $options['base_uri'] : $this->baseUris; 73 | $baseUris = \is_array($baseUris) ? $baseUris : []; 74 | $options = self::shiftBaseUri($options, $baseUris); 75 | 76 | $maxRetries = (int) ($options['max_retries'] ?? $this->maxRetries); 77 | unset($options['max_retries']); 78 | 79 | if ($maxRetries <= 0) { 80 | return new AsyncResponse($this->client, $method, $url, $options); 81 | } 82 | 83 | return new AsyncResponse($this->client, $method, $url, $options, function (ChunkInterface $chunk, AsyncContext $context) use ($method, $url, $options, $maxRetries, &$baseUris) { 84 | static $retryCount = 0; 85 | static $content = ''; 86 | static $firstChunk; 87 | 88 | $exception = null; 89 | try { 90 | if ($context->getInfo('canceled') || $chunk->isTimeout() || null !== $chunk->getInformationalStatus()) { 91 | yield $chunk; 92 | 93 | return; 94 | } 95 | } catch (TransportExceptionInterface $exception) { 96 | // catch TransportExceptionInterface to send it to the strategy 97 | } 98 | if (null !== $exception) { 99 | // always retry request that fail to resolve DNS 100 | if ('' !== $context->getInfo('primary_ip')) { 101 | $shouldRetry = $this->strategy->shouldRetry($context, null, $exception); 102 | if (null === $shouldRetry) { 103 | throw new \LogicException(\sprintf('The "%s::shouldRetry()" method must not return null when called with an exception.', $this->strategy::class)); 104 | } 105 | 106 | if (false === $shouldRetry) { 107 | yield from $this->passthru($context, $firstChunk, $content, $chunk); 108 | 109 | return; 110 | } 111 | } 112 | } elseif ($chunk->isFirst()) { 113 | if (false === $shouldRetry = $this->strategy->shouldRetry($context, null, null)) { 114 | yield from $this->passthru($context, $firstChunk, $content, $chunk); 115 | 116 | return; 117 | } 118 | 119 | // Body is needed to decide 120 | if (null === $shouldRetry) { 121 | $firstChunk = $chunk; 122 | $content = ''; 123 | 124 | return; 125 | } 126 | } else { 127 | if (!$chunk->isLast()) { 128 | $content .= $chunk->getContent(); 129 | 130 | return; 131 | } 132 | 133 | if (null === $shouldRetry = $this->strategy->shouldRetry($context, $content, null)) { 134 | throw new \LogicException(\sprintf('The "%s::shouldRetry()" method must not return null when called with a body.', $this->strategy::class)); 135 | } 136 | 137 | if (false === $shouldRetry) { 138 | yield from $this->passthru($context, $firstChunk, $content, $chunk); 139 | 140 | return; 141 | } 142 | } 143 | 144 | $context->getResponse()->cancel(); 145 | 146 | $delay = $this->getDelayFromHeader($context->getHeaders()) ?? $this->strategy->getDelay($context, !$exception && $chunk->isLast() ? $content : null, $exception); 147 | ++$retryCount; 148 | $content = ''; 149 | $firstChunk = null; 150 | 151 | $this->logger?->info('Try #{count} after {delay}ms'.($exception ? ': '.$exception->getMessage() : ', status code: '.$context->getStatusCode()), [ 152 | 'count' => $retryCount, 153 | 'delay' => $delay, 154 | ]); 155 | 156 | $context->setInfo('retry_count', $retryCount); 157 | $context->replaceRequest($method, $url, self::shiftBaseUri($options, $baseUris)); 158 | $context->pause($delay / 1000); 159 | 160 | if ($retryCount >= $maxRetries) { 161 | $context->passthru(); 162 | } 163 | }); 164 | } 165 | 166 | private function getDelayFromHeader(array $headers): ?int 167 | { 168 | if (null !== $after = $headers['retry-after'][0] ?? null) { 169 | if (is_numeric($after)) { 170 | return (int) ($after * 1000); 171 | } 172 | 173 | if (false !== $time = strtotime($after)) { 174 | return max(0, $time - time()) * 1000; 175 | } 176 | } 177 | 178 | return null; 179 | } 180 | 181 | private function passthru(AsyncContext $context, ?ChunkInterface $firstChunk, string &$content, ChunkInterface $lastChunk): \Generator 182 | { 183 | $context->passthru(); 184 | 185 | if (null !== $firstChunk) { 186 | yield $firstChunk; 187 | } 188 | 189 | if ('' !== $content) { 190 | $chunk = $context->createChunk($content); 191 | $content = ''; 192 | 193 | yield $chunk; 194 | } 195 | 196 | yield $lastChunk; 197 | } 198 | 199 | private static function shiftBaseUri(array $options, array &$baseUris): array 200 | { 201 | if ($baseUris) { 202 | $baseUri = 1 < \count($baseUris) ? array_shift($baseUris) : current($baseUris); 203 | $options['base_uri'] = \is_array($baseUri) ? $baseUri[array_rand($baseUri)] : $baseUri; 204 | } 205 | 206 | return $options; 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /ScopingHttpClient.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient; 13 | 14 | use Psr\Log\LoggerAwareInterface; 15 | use Psr\Log\LoggerInterface; 16 | use Symfony\Component\HttpClient\Exception\InvalidArgumentException; 17 | use Symfony\Contracts\HttpClient\HttpClientInterface; 18 | use Symfony\Contracts\HttpClient\ResponseInterface; 19 | use Symfony\Contracts\HttpClient\ResponseStreamInterface; 20 | use Symfony\Contracts\Service\ResetInterface; 21 | 22 | /** 23 | * Auto-configure the default options based on the requested URL. 24 | * 25 | * @author Anthony Martin 26 | */ 27 | class ScopingHttpClient implements HttpClientInterface, ResetInterface, LoggerAwareInterface 28 | { 29 | use HttpClientTrait; 30 | 31 | public function __construct( 32 | private HttpClientInterface $client, 33 | private array $defaultOptionsByRegexp, 34 | private ?string $defaultRegexp = null, 35 | ) { 36 | if (null !== $defaultRegexp && !isset($defaultOptionsByRegexp[$defaultRegexp])) { 37 | throw new InvalidArgumentException(\sprintf('No options are mapped to the provided "%s" default regexp.', $defaultRegexp)); 38 | } 39 | } 40 | 41 | public static function forBaseUri(HttpClientInterface $client, string $baseUri, array $defaultOptions = [], ?string $regexp = null): self 42 | { 43 | $regexp ??= preg_quote(implode('', self::resolveUrl(self::parseUrl('.'), self::parseUrl($baseUri)))); 44 | 45 | $defaultOptions['base_uri'] = $baseUri; 46 | 47 | return new self($client, [$regexp => $defaultOptions], $regexp); 48 | } 49 | 50 | public function request(string $method, string $url, array $options = []): ResponseInterface 51 | { 52 | $e = null; 53 | $url = self::parseUrl($url, $options['query'] ?? []); 54 | 55 | if (\is_string($options['base_uri'] ?? null)) { 56 | $options['base_uri'] = self::parseUrl($options['base_uri']); 57 | } 58 | 59 | try { 60 | $url = implode('', self::resolveUrl($url, $options['base_uri'] ?? null)); 61 | } catch (InvalidArgumentException $e) { 62 | if (null === $this->defaultRegexp) { 63 | throw $e; 64 | } 65 | 66 | $defaultOptions = $this->defaultOptionsByRegexp[$this->defaultRegexp]; 67 | $options = self::mergeDefaultOptions($options, $defaultOptions, true); 68 | if (\is_string($options['base_uri'] ?? null)) { 69 | $options['base_uri'] = self::parseUrl($options['base_uri']); 70 | } 71 | $url = implode('', self::resolveUrl($url, $options['base_uri'] ?? null, $defaultOptions['query'] ?? [])); 72 | } 73 | 74 | foreach ($this->defaultOptionsByRegexp as $regexp => $defaultOptions) { 75 | if (preg_match("{{$regexp}}A", $url)) { 76 | if (null === $e || $regexp !== $this->defaultRegexp) { 77 | $options = self::mergeDefaultOptions($options, $defaultOptions, true); 78 | } 79 | break; 80 | } 81 | } 82 | 83 | return $this->client->request($method, $url, $options); 84 | } 85 | 86 | public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface 87 | { 88 | return $this->client->stream($responses, $timeout); 89 | } 90 | 91 | public function reset(): void 92 | { 93 | if ($this->client instanceof ResetInterface) { 94 | $this->client->reset(); 95 | } 96 | } 97 | 98 | /** 99 | * @deprecated since Symfony 7.1, configure the logger on the wrapped HTTP client directly instead 100 | */ 101 | public function setLogger(LoggerInterface $logger): void 102 | { 103 | trigger_deprecation('symfony/http-client', '7.1', 'Configure the logger on the wrapped HTTP client directly instead.'); 104 | 105 | if ($this->client instanceof LoggerAwareInterface) { 106 | $this->client->setLogger($logger); 107 | } 108 | } 109 | 110 | public function withOptions(array $options): static 111 | { 112 | $clone = clone $this; 113 | $clone->client = $this->client->withOptions($options); 114 | 115 | return $clone; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Test/HarFileResponseFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient\Test; 13 | 14 | use Symfony\Component\HttpClient\Exception\TransportException; 15 | use Symfony\Component\HttpClient\Response\MockResponse; 16 | use Symfony\Contracts\HttpClient\ResponseInterface; 17 | 18 | /** 19 | * See: https://w3c.github.io/web-performance/specs/HAR/Overview.html. 20 | * 21 | * @author Gary PEGEOT 22 | */ 23 | class HarFileResponseFactory 24 | { 25 | public function __construct(private string $archiveFile) 26 | { 27 | } 28 | 29 | public function setArchiveFile(string $archiveFile): void 30 | { 31 | $this->archiveFile = $archiveFile; 32 | } 33 | 34 | public function __invoke(string $method, string $url, array $options): ResponseInterface 35 | { 36 | if (!is_file($this->archiveFile)) { 37 | throw new \InvalidArgumentException(\sprintf('Invalid file path provided: "%s".', $this->archiveFile)); 38 | } 39 | 40 | $json = json_decode(json: file_get_contents($this->archiveFile), associative: true, flags: \JSON_THROW_ON_ERROR); 41 | 42 | foreach ($json['log']['entries'] as $entry) { 43 | /** 44 | * @var array{status: int, headers: array, content: array} $response 45 | * @var array{method: string, url: string, postData: array} $request 46 | */ 47 | ['response' => $response, 'request' => $request, 'startedDateTime' => $startedDateTime] = $entry; 48 | 49 | $body = $this->getContent($response['content']); 50 | $entryMethod = $request['method']; 51 | $entryUrl = $request['url']; 52 | $requestBody = $options['body'] ?? null; 53 | 54 | if ($method !== $entryMethod || $url !== $entryUrl) { 55 | continue; 56 | } 57 | 58 | if (null !== $requestBody && $requestBody !== $this->getContent($request['postData'] ?? [])) { 59 | continue; 60 | } 61 | 62 | $info = [ 63 | 'http_code' => $response['status'], 64 | 'http_method' => $entryMethod, 65 | 'response_headers' => [], 66 | 'start_time' => strtotime($startedDateTime), 67 | 'url' => $entryUrl, 68 | ]; 69 | 70 | /** @var array{name: string, value: string} $header */ 71 | foreach ($response['headers'] as $header) { 72 | ['name' => $name, 'value' => $value] = $header; 73 | 74 | $info['response_headers'][$name][] = $value; 75 | } 76 | 77 | return new MockResponse($body, $info); 78 | } 79 | 80 | throw new TransportException(\sprintf('File "%s" does not contain a response for HTTP request "%s" "%s".', $this->archiveFile, $method, $url)); 81 | } 82 | 83 | /** 84 | * @param array{text: string, encoding: string} $content 85 | */ 86 | private function getContent(array $content): string 87 | { 88 | $text = $content['text'] ?? ''; 89 | $encoding = $content['encoding'] ?? null; 90 | 91 | return match ($encoding) { 92 | 'base64' => base64_decode($text), 93 | null => $text, 94 | default => throw new \InvalidArgumentException(\sprintf('Unsupported encoding "%s", currently only base64 is supported.', $encoding)), 95 | }; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /ThrottlingHttpClient.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient; 13 | 14 | use Symfony\Component\RateLimiter\LimiterInterface; 15 | use Symfony\Contracts\HttpClient\HttpClientInterface; 16 | use Symfony\Contracts\HttpClient\ResponseInterface; 17 | use Symfony\Contracts\Service\ResetInterface; 18 | 19 | /** 20 | * Limits the number of requests within a certain period. 21 | */ 22 | class ThrottlingHttpClient implements HttpClientInterface, ResetInterface 23 | { 24 | use DecoratorTrait { 25 | reset as private traitReset; 26 | } 27 | 28 | public function __construct( 29 | HttpClientInterface $client, 30 | private readonly LimiterInterface $rateLimiter, 31 | ) { 32 | $this->client = $client; 33 | } 34 | 35 | public function request(string $method, string $url, array $options = []): ResponseInterface 36 | { 37 | $response = $this->client->request($method, $url, $options); 38 | 39 | if (0 < $waitDuration = $this->rateLimiter->reserve()->getWaitDuration()) { 40 | $response->getInfo('pause_handler')($waitDuration); 41 | } 42 | 43 | return $response; 44 | } 45 | 46 | public function reset(): void 47 | { 48 | $this->traitReset(); 49 | $this->rateLimiter->reset(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /TraceableHttpClient.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient; 13 | 14 | use Psr\Log\LoggerAwareInterface; 15 | use Psr\Log\LoggerInterface; 16 | use Symfony\Component\HttpClient\Response\ResponseStream; 17 | use Symfony\Component\HttpClient\Response\TraceableResponse; 18 | use Symfony\Component\Stopwatch\Stopwatch; 19 | use Symfony\Contracts\HttpClient\HttpClientInterface; 20 | use Symfony\Contracts\HttpClient\ResponseInterface; 21 | use Symfony\Contracts\HttpClient\ResponseStreamInterface; 22 | use Symfony\Contracts\Service\ResetInterface; 23 | 24 | /** 25 | * @author Jérémy Romey 26 | */ 27 | final class TraceableHttpClient implements HttpClientInterface, ResetInterface, LoggerAwareInterface 28 | { 29 | private \ArrayObject $tracedRequests; 30 | 31 | public function __construct( 32 | private HttpClientInterface $client, 33 | private ?Stopwatch $stopwatch = null, 34 | private ?\Closure $disabled = null, 35 | ) { 36 | $this->tracedRequests = new \ArrayObject(); 37 | } 38 | 39 | public function request(string $method, string $url, array $options = []): ResponseInterface 40 | { 41 | if ($this->disabled?->__invoke()) { 42 | return new TraceableResponse($this->client, $this->client->request($method, $url, $options)); 43 | } 44 | 45 | $content = null; 46 | $traceInfo = []; 47 | $tracedRequest = [ 48 | 'method' => $method, 49 | 'url' => $url, 50 | 'options' => $options, 51 | 'info' => &$traceInfo, 52 | 'content' => &$content, 53 | ]; 54 | $onProgress = $options['on_progress'] ?? null; 55 | 56 | if (false === ($options['extra']['trace_content'] ?? true)) { 57 | unset($content); 58 | $content = false; 59 | unset($tracedRequest['options']['body'], $tracedRequest['options']['json']); 60 | } 61 | $this->tracedRequests[] = $tracedRequest; 62 | 63 | $options['on_progress'] = function (int $dlNow, int $dlSize, array $info) use (&$traceInfo, $onProgress) { 64 | $traceInfo = $info; 65 | 66 | if (null !== $onProgress) { 67 | $onProgress($dlNow, $dlSize, $info); 68 | } 69 | }; 70 | 71 | return new TraceableResponse($this->client, $this->client->request($method, $url, $options), $content, $this->stopwatch?->start("$method $url", 'http_client')); 72 | } 73 | 74 | public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface 75 | { 76 | if ($responses instanceof TraceableResponse) { 77 | $responses = [$responses]; 78 | } 79 | 80 | return new ResponseStream(TraceableResponse::stream($this->client, $responses, $timeout)); 81 | } 82 | 83 | public function getTracedRequests(): array 84 | { 85 | return $this->tracedRequests->getArrayCopy(); 86 | } 87 | 88 | public function reset(): void 89 | { 90 | if ($this->client instanceof ResetInterface) { 91 | $this->client->reset(); 92 | } 93 | 94 | $this->tracedRequests->exchangeArray([]); 95 | } 96 | 97 | /** 98 | * @deprecated since Symfony 7.1, configure the logger on the wrapped HTTP client directly instead 99 | */ 100 | public function setLogger(LoggerInterface $logger): void 101 | { 102 | trigger_deprecation('symfony/http-client', '7.1', 'Configure the logger on the wrapped HTTP client directly instead.'); 103 | 104 | if ($this->client instanceof LoggerAwareInterface) { 105 | $this->client->setLogger($logger); 106 | } 107 | } 108 | 109 | public function withOptions(array $options): static 110 | { 111 | $clone = clone $this; 112 | $clone->client = $this->client->withOptions($options); 113 | 114 | return $clone; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /UriTemplateHttpClient.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\HttpClient; 13 | 14 | use Symfony\Contracts\HttpClient\HttpClientInterface; 15 | use Symfony\Contracts\HttpClient\ResponseInterface; 16 | use Symfony\Contracts\Service\ResetInterface; 17 | 18 | class UriTemplateHttpClient implements HttpClientInterface, ResetInterface 19 | { 20 | use DecoratorTrait; 21 | 22 | /** 23 | * @param (\Closure(string $url, array $vars): string)|null $expander 24 | */ 25 | public function __construct(?HttpClientInterface $client = null, private ?\Closure $expander = null, private array $defaultVars = []) 26 | { 27 | $this->client = $client ?? HttpClient::create(); 28 | } 29 | 30 | public function request(string $method, string $url, array $options = []): ResponseInterface 31 | { 32 | $vars = $this->defaultVars; 33 | 34 | if (\array_key_exists('vars', $options)) { 35 | if (!\is_array($options['vars'])) { 36 | throw new \InvalidArgumentException('The "vars" option must be an array.'); 37 | } 38 | 39 | $vars = [...$vars, ...$options['vars']]; 40 | unset($options['vars']); 41 | } 42 | 43 | if ($vars) { 44 | $url = ($this->expander ??= $this->createExpanderFromPopularVendors())($url, $vars); 45 | } 46 | 47 | return $this->client->request($method, $url, $options); 48 | } 49 | 50 | public function withOptions(array $options): static 51 | { 52 | if (!\is_array($options['vars'] ?? [])) { 53 | throw new \InvalidArgumentException('The "vars" option must be an array.'); 54 | } 55 | 56 | $clone = clone $this; 57 | $clone->defaultVars = [...$clone->defaultVars, ...$options['vars'] ?? []]; 58 | unset($options['vars']); 59 | 60 | $clone->client = $this->client->withOptions($options); 61 | 62 | return $clone; 63 | } 64 | 65 | /** 66 | * @return \Closure(string $url, array $vars): string 67 | */ 68 | private function createExpanderFromPopularVendors(): \Closure 69 | { 70 | if (class_exists(\GuzzleHttp\UriTemplate\UriTemplate::class)) { 71 | return \GuzzleHttp\UriTemplate\UriTemplate::expand(...); 72 | } 73 | 74 | if (class_exists(\League\Uri\UriTemplate::class)) { 75 | return static fn (string $url, array $vars): string => (new \League\Uri\UriTemplate($url))->expand($vars); 76 | } 77 | 78 | if (class_exists(\Rize\UriTemplate::class)) { 79 | return (new \Rize\UriTemplate())->expand(...); 80 | } 81 | 82 | throw new \LogicException('Support for URI template requires a vendor to expand the URI. Run "composer require guzzlehttp/uri-template" or pass your own expander \Closure implementation.'); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/http-client", 3 | "type": "library", 4 | "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", 5 | "keywords": ["http"], 6 | "homepage": "https://symfony.com", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Nicolas Grekas", 11 | "email": "p@tchwork.com" 12 | }, 13 | { 14 | "name": "Symfony Community", 15 | "homepage": "https://symfony.com/contributors" 16 | } 17 | ], 18 | "provide": { 19 | "php-http/async-client-implementation": "*", 20 | "php-http/client-implementation": "*", 21 | "psr/http-client-implementation": "1.0", 22 | "symfony/http-client-implementation": "3.0" 23 | }, 24 | "require": { 25 | "php": ">=8.2", 26 | "psr/log": "^1|^2|^3", 27 | "symfony/deprecation-contracts": "^2.5|^3", 28 | "symfony/http-client-contracts": "~3.4.4|^3.5.2", 29 | "symfony/service-contracts": "^2.5|^3" 30 | }, 31 | "require-dev": { 32 | "amphp/http-client": "^4.2.1|^5.0", 33 | "amphp/http-tunnel": "^1.0|^2.0", 34 | "amphp/socket": "^1.1", 35 | "guzzlehttp/promises": "^1.4|^2.0", 36 | "nyholm/psr7": "^1.0", 37 | "php-http/httplug": "^1.0|^2.0", 38 | "psr/http-client": "^1.0", 39 | "symfony/amphp-http-client-meta": "^1.0|^2.0", 40 | "symfony/dependency-injection": "^6.4|^7.0", 41 | "symfony/http-kernel": "^6.4|^7.0", 42 | "symfony/messenger": "^6.4|^7.0", 43 | "symfony/process": "^6.4|^7.0", 44 | "symfony/rate-limiter": "^6.4|^7.0", 45 | "symfony/stopwatch": "^6.4|^7.0" 46 | }, 47 | "conflict": { 48 | "amphp/amp": "<2.5", 49 | "php-http/discovery": "<1.15", 50 | "symfony/http-foundation": "<6.4" 51 | }, 52 | "autoload": { 53 | "psr-4": { "Symfony\\Component\\HttpClient\\": "" }, 54 | "exclude-from-classmap": [ 55 | "/Tests/" 56 | ] 57 | }, 58 | "minimum-stability": "dev" 59 | } 60 | --------------------------------------------------------------------------------