├── 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
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
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
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
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
28 | *
29 | * @internal
30 | */
31 | final class HttplugWaitLoop
32 | {
33 | /**
34 | * @param \SplObjectStorage
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
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é
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é