This commit is contained in:
cutemeli
2025-12-22 10:35:30 +00:00
parent 0bfc6c8425
commit 5ce7ca2c5d
38927 changed files with 0 additions and 4594700 deletions

View File

@@ -1,198 +0,0 @@
<?php
namespace Http\Client\Socket;
use Http\Client\HttpClient;
use Http\Client\Socket\Exception\ConnectionException;
use Http\Client\Socket\Exception\InvalidRequestException;
use Http\Client\Socket\Exception\SSLConnectionException;
use Http\Client\Socket\Exception\TimeoutException;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Socket Http Client.
*
* Use stream and socket capabilities of the core of PHP to send HTTP requests
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
class Client implements HttpClient
{
use RequestWriter;
use ResponseReader;
/**
* @var array{remote_socket: string|null, timeout: int, stream_context: resource, stream_context_options: array<string, mixed>, stream_context_param: array<string, mixed>, ssl: ?boolean, write_buffer_size: int, ssl_method: int}
*/
private $config;
/**
* Constructor.
*
* @param array{remote_socket?: string|null, timeout?: int, stream_context?: resource, stream_context_options?: array<string, mixed>, stream_context_param?: array<string, mixed>, ssl?: ?boolean, write_buffer_size?: int, ssl_method?: int}|ResponseFactoryInterface $config1
* @param array{remote_socket?: string|null, timeout?: int, stream_context?: resource, stream_context_options?: array<string, mixed>, stream_context_param?: array<string, mixed>, ssl?: ?boolean, write_buffer_size?: int, ssl_method?: int}|null $config2 Mistake when refactoring the constructor from version 1 to version 2 - used as $config if set and $configOrResponseFactory is a response factory instance
* @param array{remote_socket?: string|null, timeout?: int, stream_context?: resource, stream_context_options?: array<string, mixed>, stream_context_param?: array<string, mixed>, ssl?: ?boolean, write_buffer_size?: int, ssl_method?: int} $config intended for version 1 BC, used as $config if $config2 is not set and $configOrResponseFactory is a response factory instance
*
* string|null remote_socket Remote entrypoint (can be a tcp or unix domain address)
* int timeout Timeout before canceling request
* stream resource The initialized stream context, if not set the context is created from the options and param.
* array<string, mixed> stream_context_options Context options as defined in the PHP documentation
* array<string, mixed> stream_context_param Context params as defined in the PHP documentation
* boolean ssl Use ssl, default to scheme from request, false if not present
* int write_buffer_size Buffer when writing the request body, defaults to 8192
* int ssl_method Crypto method for ssl/tls, see PHP doc, defaults to STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT
*/
public function __construct($config1 = [], $config2 = null, array $config = [])
{
if (\is_array($config1)) {
$this->config = $this->configure($config1);
return;
}
@trigger_error('Passing a Psr\Http\Message\ResponseFactoryInterface to SocketClient is deprecated, and will be removed in 3.0, you should only pass config options.', E_USER_DEPRECATED);
$this->config = $this->configure($config2 ?: $config);
}
/**
* {@inheritdoc}
*/
public function sendRequest(RequestInterface $request): ResponseInterface
{
$remote = $this->config['remote_socket'];
$useSsl = $this->config['ssl'];
if (!$request->hasHeader('Connection')) {
$request = $request->withHeader('Connection', 'close');
}
if (null === $remote) {
$remote = $this->determineRemoteFromRequest($request);
}
if (null === $useSsl) {
$useSsl = ('https' === $request->getUri()->getScheme());
}
$socket = $this->createSocket($request, $remote, $useSsl);
try {
$this->writeRequest($socket, $request, $this->config['write_buffer_size']);
$response = $this->readResponse($request, $socket);
} catch (\Exception $e) {
$this->closeSocket($socket);
throw $e;
}
return $response;
}
/**
* Create the socket to write request and read response on it.
*
* @param RequestInterface $request Request for
* @param string $remote Entrypoint for the connection
* @param bool $useSsl Whether to use ssl or not
*
* @throws ConnectionException|SSLConnectionException When the connection fail
*
* @return resource Socket resource
*/
protected function createSocket(RequestInterface $request, string $remote, bool $useSsl)
{
$errNo = null;
$errMsg = null;
$socket = @stream_socket_client($remote, $errNo, $errMsg, floor($this->config['timeout'] / 1000), STREAM_CLIENT_CONNECT, $this->config['stream_context']);
if (false === $socket) {
if (110 === $errNo) {
throw new TimeoutException($errMsg, $request);
}
throw new ConnectionException($errMsg, $request);
}
stream_set_timeout($socket, (int) floor($this->config['timeout'] / 1000), $this->config['timeout'] % 1000);
if ($useSsl && false === @stream_socket_enable_crypto($socket, true, $this->config['ssl_method'])) {
throw new SSLConnectionException(sprintf('Cannot enable tls: %s', error_get_last()['message'] ?? 'no error reported'), $request);
}
return $socket;
}
/**
* Close the socket, used when having an error.
*
* @param resource $socket
*
* @return void
*/
protected function closeSocket($socket)
{
fclose($socket);
}
/**
* Return configuration for the socket client.
*
* @param array{remote_socket?: string|null, timeout?: int, stream_context?: resource, stream_context_options?: array<string, mixed>, stream_context_param?: array<string, mixed>, ssl?: ?boolean, write_buffer_size?: int, ssl_method?: int} $config
*
* @return array{remote_socket: string|null, timeout: int, stream_context: resource, stream_context_options: array<string, mixed>, stream_context_param: array<string, mixed>, ssl: ?boolean, write_buffer_size: int, ssl_method: int}
*/
protected function configure(array $config = [])
{
$resolver = new OptionsResolver();
$resolver->setDefaults([
'remote_socket' => null,
'timeout' => null,
'stream_context_options' => [],
'stream_context_param' => [],
'ssl' => null,
'write_buffer_size' => 8192,
'ssl_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT,
]);
$resolver->setDefault('stream_context', function (Options $options) {
return stream_context_create($options['stream_context_options'], $options['stream_context_param']);
});
$resolver->setDefault('timeout', ((int) ini_get('default_socket_timeout')) * 1000);
$resolver->setAllowedTypes('stream_context_options', 'array');
$resolver->setAllowedTypes('stream_context_param', 'array');
$resolver->setAllowedTypes('stream_context', 'resource');
$resolver->setAllowedTypes('ssl', ['bool', 'null']);
return $resolver->resolve($config);
}
/**
* Return remote socket from the request.
*
* @throws InvalidRequestException When no remote can be determined from the request
*
* @return string
*/
private function determineRemoteFromRequest(RequestInterface $request)
{
if (!$request->hasHeader('Host') && '' === $request->getUri()->getHost()) {
throw new InvalidRequestException('Remote is not defined and we cannot determine a connection endpoint for this request (no Host header)', $request);
}
$host = $request->getUri()->getHost();
$port = $request->getUri()->getPort() ?: ('https' === $request->getUri()->getScheme() ? 443 : 80);
$endpoint = sprintf('%s:%s', $host, $port);
// If use the host header if present for the endpoint
if (empty($host) && $request->hasHeader('Host')) {
$endpoint = $request->getHeaderLine('Host');
}
return sprintf('tcp://%s', $endpoint);
}
}

View File

@@ -1,7 +0,0 @@
<?php
namespace Http\Client\Socket\Exception;
class BrokenPipeException extends NetworkException
{
}

View File

@@ -1,7 +0,0 @@
<?php
namespace Http\Client\Socket\Exception;
class ConnectionException extends NetworkException
{
}

View File

@@ -1,7 +0,0 @@
<?php
namespace Http\Client\Socket\Exception;
class InvalidRequestException extends NetworkException
{
}

View File

@@ -1,26 +0,0 @@
<?php
namespace Http\Client\Socket\Exception;
use Psr\Http\Client\NetworkExceptionInterface;
use Psr\Http\Message\RequestInterface;
class NetworkException extends \RuntimeException implements NetworkExceptionInterface
{
/**
* @var RequestInterface
*/
private $request;
public function __construct(string $message, RequestInterface $request, ?\Exception $previous = null)
{
$this->request = $request;
parent::__construct($message, 0, $previous);
}
public function getRequest(): RequestInterface
{
return $this->request;
}
}

View File

@@ -1,7 +0,0 @@
<?php
namespace Http\Client\Socket\Exception;
class SSLConnectionException extends NetworkException
{
}

View File

@@ -1,9 +0,0 @@
<?php
namespace Http\Client\Socket\Exception;
use Psr\Http\Client\ClientExceptionInterface;
class StreamException extends \RuntimeException implements ClientExceptionInterface
{
}

View File

@@ -1,7 +0,0 @@
<?php
namespace Http\Client\Socket\Exception;
class TimeoutException extends NetworkException
{
}

View File

@@ -1,131 +0,0 @@
<?php
namespace Http\Client\Socket;
use Http\Client\Socket\Exception\BrokenPipeException;
use Psr\Http\Message\RequestInterface;
/**
* Method for writing request.
*
* Mainly used by SocketHttpClient
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
trait RequestWriter
{
/**
* Write a request to a socket.
*
* @param resource $socket
*
* @return void
*
* @throws BrokenPipeException
*/
protected function writeRequest($socket, RequestInterface $request, int $bufferSize = 8192)
{
if (false === $this->fwrite($socket, $this->transformRequestHeadersToString($request))) {
throw new BrokenPipeException('Failed to send request, underlying socket not accessible, (BROKEN EPIPE)', $request);
}
if ($request->getBody()->isReadable()) {
$this->writeBody($socket, $request, $bufferSize);
}
}
/**
* Write Body of the request.
*
* @param resource $socket
*
* @return void
*
* @throws BrokenPipeException
*/
protected function writeBody($socket, RequestInterface $request, int $bufferSize = 8192)
{
$body = $request->getBody();
if ($body->isSeekable()) {
$body->rewind();
}
while (!$body->eof()) {
$buffer = $body->read($bufferSize);
if (false === $this->fwrite($socket, $buffer)) {
throw new BrokenPipeException('An error occur when writing request to client (BROKEN EPIPE)', $request);
}
}
}
/**
* Produce the header of request as a string based on a PSR Request.
*/
protected function transformRequestHeadersToString(RequestInterface $request): string
{
$message = vsprintf('%s %s HTTP/%s', [
strtoupper($request->getMethod()),
$request->getRequestTarget(),
$request->getProtocolVersion(),
])."\r\n";
foreach ($request->getHeaders() as $name => $values) {
$message .= $name.': '.implode(', ', $values)."\r\n";
}
$message .= "\r\n";
return $message;
}
/**
* Replace fwrite behavior as api is broken in PHP.
*
* @see https://secure.phabricator.com/rPHU69490c53c9c2ef2002bc2dd4cecfe9a4b080b497
*
* @param resource $stream The stream resource
*
* @return bool|int false if pipe is broken, number of bytes written otherwise
*/
private function fwrite($stream, string $bytes)
{
if (!strlen($bytes)) {
return 0;
}
$result = @fwrite($stream, $bytes);
if (0 !== $result) {
// In cases where some bytes are witten (`$result > 0`) or
// an error occurs (`$result === false`), the behavior of fwrite() is
// correct. We can return the value as-is.
return $result;
}
// If we make it here, we performed a 0-length write. Try to distinguish
// between EAGAIN and EPIPE. To do this, we're going to `stream_select()`
// the stream, write to it again if PHP claims that it's writable, and
// consider the pipe broken if the write fails.
$read = [];
$write = [$stream];
$except = [];
@stream_select($read, $write, $except, 0);
if (!$write) {
// The stream isn't writable, so we conclude that it probably really is
// blocked and the underlying error was EAGAIN. Return 0 to indicate that
// no data could be written yet.
return 0;
}
// If we make it here, PHP **just** claimed that this stream is writable, so
// perform a write. If the write also fails, conclude that these failures are
// EPIPE or some other permanent failure.
$result = @fwrite($stream, $bytes);
if (0 !== $result) {
// The write worked or failed explicitly. This value is fine to return.
return $result;
}
// We performed a 0-length write, were told that the stream was writable, and
// then immediately performed another 0-length write. Conclude that the pipe
// is broken and return `false`.
return false;
}
}

View File

@@ -1,104 +0,0 @@
<?php
namespace Http\Client\Socket;
use Http\Client\Socket\Exception\BrokenPipeException;
use Http\Client\Socket\Exception\TimeoutException;
use Http\Message\ResponseFactory;
use Nyholm\Psr7\Response;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Method for reading response.
*
* Mainly used by SocketHttpClient
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
trait ResponseReader
{
/**
* @var ResponseFactory For creating response
*/
protected $responseFactory;
/**
* Read a response from a socket.
*
* @param resource $socket
*
* @throws TimeoutException When the socket timed out
* @throws BrokenPipeException When the response cannot be read
*/
protected function readResponse(RequestInterface $request, $socket): ResponseInterface
{
$headers = [];
$reason = null;
while (false !== ($line = fgets($socket))) {
if ('' === rtrim($line)) {
break;
}
$headers[] = trim($line);
}
$metadatas = stream_get_meta_data($socket);
if (array_key_exists('timed_out', $metadatas) && true === $metadatas['timed_out']) {
throw new TimeoutException('Error while reading response, stream timed out', $request, null);
}
$header = array_shift($headers);
$parts = null !== $header ? explode(' ', $header, 3) : [];
if (count($parts) <= 1) {
throw new BrokenPipeException('Cannot read the response', $request);
}
$protocol = substr($parts[0], -3);
$status = $parts[1];
if (isset($parts[2])) {
$reason = $parts[2];
}
// Set the size on the stream if it was returned in the response
$responseHeaders = [];
foreach ($headers as $header) {
$headerParts = explode(':', $header, 2);
if (!array_key_exists(trim($headerParts[0]), $responseHeaders)) {
$responseHeaders[trim($headerParts[0])] = [];
}
$responseHeaders[trim($headerParts[0])][] = isset($headerParts[1])
? trim($headerParts[1])
: '';
}
$response = new Response((int) $status, $responseHeaders, null, $protocol, $reason);
$stream = $this->createStream($socket, $request, $response);
return $response->withBody($stream);
}
/**
* Create the stream.
*
* @param resource $socket
*/
protected function createStream($socket, RequestInterface $request, ResponseInterface $response): Stream
{
$size = null;
if ($response->hasHeader('Content-Length')) {
$size = (int) $response->getHeaderLine('Content-Length');
}
if ($size < 0) {
$size = null;
}
return new Stream($request, $socket, $size);
}
}

View File

@@ -1,281 +0,0 @@
<?php
namespace Http\Client\Socket;
use Http\Client\Socket\Exception\StreamException;
use Http\Client\Socket\Exception\TimeoutException;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\StreamInterface;
/**
* Stream implementation for Socket Client.
*
* This implementation is used to have a Stream which react better to the Socket Client behavior.
*
* The main advantage is you can get the response of a request even if it's not finish, the response is available
* as soon as all headers are received, this stream will have the remaining socket used for the request / response
* call.
*
* It is only readable once, if you want to read the content multiple times, you can store contents of this
* stream into a variable or encapsulate it in a buffered stream.
*
* Writing and seeking is disable to avoid weird behaviors.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
class Stream implements StreamInterface
{
/** @var resource|null Underlying socket */
private $socket;
/**
* @var bool Is stream detached
*/
private $isDetached = false;
/**
* @var int<0, max>|null Size of the stream, so we know what we must read, null if not available (i.e. a chunked stream)
*/
private $size;
/**
* @var int<0, max> Size of the stream readed, to avoid reading more than available and have the user blocked
*/
private $readed = 0;
/**
* @var RequestInterface request associated to this stream
*/
private $request;
/**
* Create the stream.
*
* @param resource $socket
* @param int<0, max>|null $size
*/
public function __construct(RequestInterface $request, $socket, ?int $size = null)
{
$this->socket = $socket;
$this->size = $size;
$this->request = $request;
}
/**
* {@inheritdoc}
*/
public function __toString(): string
{
try {
return $this->getContents();
} catch (\Exception $e) {
return '';
}
}
/**
* {@inheritdoc}
*/
public function close(): void
{
if ($this->isDetached || null === $this->socket) {
throw new StreamException('Stream is detached');
}
fclose($this->socket);
}
/**
* {@inheritdoc}
*/
public function detach()
{
if ($this->isDetached) {
return null;
}
$this->isDetached = true;
$socket = $this->socket;
$this->socket = null;
return $socket;
}
/**
* {@inheritdoc}
*
* @return int<0, max>|null
*/
public function getSize(): ?int
{
return $this->size;
}
/**
* {@inheritdoc}
*/
public function tell(): int
{
if ($this->isDetached || null === $this->socket) {
throw new StreamException('Stream is detached');
}
$tell = ftell($this->socket);
if (false === $tell) {
throw new StreamException('ftell returned false');
}
return $tell;
}
/**
* {@inheritdoc}
*/
public function eof(): bool
{
if ($this->isDetached || null === $this->socket) {
throw new StreamException('Stream is detached');
}
return feof($this->socket);
}
/**
* {@inheritdoc}
*/
public function isSeekable(): bool
{
return false;
}
/**
* {@inheritdoc}
*
* @return void
*/
public function seek($offset, $whence = SEEK_SET): void
{
throw new StreamException('This stream is not seekable');
}
/**
* {@inheritdoc}
*
* @return void
*/
public function rewind(): void
{
throw new StreamException('This stream is not seekable');
}
/**
* {@inheritdoc}
*/
public function isWritable(): bool
{
return false;
}
/**
* {@inheritdoc}
*/
public function write($string): int
{
throw new StreamException('This stream is not writable');
}
/**
* {@inheritdoc}
*/
public function isReadable(): bool
{
return true;
}
/**
* {@inheritdoc}
*
* @param int<0, max> $length
*/
public function read($length): string
{
if ($this->isDetached || null === $this->socket) {
throw new StreamException('Stream is detached');
}
if (null === $this->getSize()) {
$read = fread($this->socket, $length);
if (false === $read) {
throw new StreamException('Failed to read from stream');
}
return $read;
}
if ($this->getSize() === $this->readed) {
return '';
}
// Even if we request a length a non blocking stream can return less data than asked
$read = fread($this->socket, $length);
if (false === $read) {
// PHP 8
if ($this->getMetadata('timed_out')) {
throw new TimeoutException('Stream timed out while reading data', $this->request);
}
throw new StreamException('Failed to read from stream');
}
// PHP 7: fread does not return false when timing out
if ($this->getMetadata('timed_out')) {
throw new TimeoutException('Stream timed out while reading data', $this->request);
}
$this->readed += strlen($read);
return $read;
}
/**
* {@inheritdoc}
*/
public function getContents(): string
{
if ($this->isDetached || null === $this->socket) {
throw new StreamException('Stream is detached');
}
if (null === $this->getSize()) {
$contents = stream_get_contents($this->socket);
if (false === $contents) {
throw new StreamException('failed to get contents of stream');
}
return $contents;
}
$contents = '';
$toread = $this->getSize() - $this->readed;
while ($toread > 0) {
$contents .= $this->read($toread);
$toread = $this->getSize() - $this->readed;
}
return $contents;
}
/**
* {@inheritdoc}
*/
public function getMetadata($key = null)
{
if ($this->isDetached || null === $this->socket) {
throw new StreamException('Stream is detached');
}
$meta = stream_get_meta_data($this->socket);
if (null === $key) {
return $meta;
}
return $meta[$key];
}
}