From 2b27980b256a2fb38465e41d7f22e7a82dce316a Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:00:11 +0100 Subject: [PATCH 1/5] Add typed client exceptions, PSR-7 constants, and shared adapter test contract Introduce a typed exception hierarchy for client failures (connection, DNS, TLS, proxy, protocol, invalid URI/response, adapter init/precondition) and PSR-7 constant helpers (Header, Method, ContentType). Adapters now map transport failures onto these exceptions. Tests share a single AdapterContract run against both the cURL and Swoole coroutine adapters. The test HTTP server is a deep module: Http::serve(), Http::raw(), and Http::unbound() each manage the full server lifecycle (allocate, spawn, await readiness, tear down) behind a callable, and the extension guard runs once in setUp(). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 64 +- src/Client.php | 5 +- src/Client/Adapter/Curl/Client.php | 141 ++++- src/Client/Adapter/SwooleCoroutine/Client.php | 242 +++++++- .../AdapterInitializationException.php | 7 + .../AdapterPreconditionException.php | 7 + src/Client/Exception/ConnectionException.php | 7 + src/Client/Exception/DnsException.php | 7 + .../Exception/InvalidResponseException.php | 7 + src/Client/Exception/InvalidUriException.php | 7 + src/Client/Exception/ProtocolException.php | 7 + src/Client/Exception/ProxyException.php | 7 + src/Client/Exception/TlsException.php | 7 + src/Psr7/ContentType.php | 28 + src/Psr7/Header.php | 46 ++ src/Psr7/Method.php | 28 + src/Psr7/Request/Factory.php | 34 +- src/Psr7/Response.php | 2 +- src/Psr7/Stream.php | 4 +- tests/Client/Adapter/AdapterContract.php | 568 ++++++++++++++++++ tests/Client/Adapter/Curl/ClientTest.php | 262 ++------ .../Adapter/SwooleCoroutine/ClientTest.php | 241 ++------ tests/Client/Adapter/TimeoutTest.php | 39 +- tests/Client/ExceptionTest.php | 44 ++ tests/Psr7/ConstantsTest.php | 46 ++ tests/Server/Http.php | 204 +++++++ tests/server.php | 91 +++ 27 files changed, 1620 insertions(+), 532 deletions(-) create mode 100644 src/Client/Exception/AdapterInitializationException.php create mode 100644 src/Client/Exception/AdapterPreconditionException.php create mode 100644 src/Client/Exception/ConnectionException.php create mode 100644 src/Client/Exception/DnsException.php create mode 100644 src/Client/Exception/InvalidResponseException.php create mode 100644 src/Client/Exception/InvalidUriException.php create mode 100644 src/Client/Exception/ProtocolException.php create mode 100644 src/Client/Exception/ProxyException.php create mode 100644 src/Client/Exception/TlsException.php create mode 100644 src/Psr7/ContentType.php create mode 100644 src/Psr7/Header.php create mode 100644 src/Psr7/Method.php create mode 100644 tests/Client/Adapter/AdapterContract.php create mode 100644 tests/Client/ExceptionTest.php create mode 100644 tests/Psr7/ConstantsTest.php create mode 100644 tests/Server/Http.php diff --git a/README.md b/README.md index 9e71290..3de3753 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Use `ext-curl` for the cURL adapter and `ext-swoole` for the Swoole coroutine ad use Utopia\Client; use Utopia\Client\Adapter\Curl\Client as CurlAdapter; +use Utopia\Psr7\Method; use Utopia\Psr7\Request; require __DIR__ . '/vendor/autoload.php'; @@ -35,7 +36,7 @@ require __DIR__ . '/vendor/autoload.php'; $client = new Client(new CurlAdapter()); $requestFactory = new Request\Factory(); -$request = $requestFactory->json('POST', 'https://example.com/users', [ +$request = $requestFactory->json(Method::POST, 'https://example.com/users', [ 'name' => 'Ada', ]); @@ -56,11 +57,14 @@ Client defaults are immutable. Each `with*()` method returns a configured clone. ```php withBaseUri('https://api.example.com/v1') ->withHeaders([ - 'Accept' => 'application/json', - 'User-Agent' => 'Acme API Client', + Header::ACCEPT => ContentType::JSON, + Header::USER_AGENT => 'Acme API Client', ]) ->withBearerAuth('token'); ``` @@ -70,9 +74,12 @@ Configured headers are defaults. If a request already has the same header, the r ```php createRequest('GET', 'users') - ->withHeader('Accept', 'application/xml'); + ->createRequest(Method::GET, 'users') + ->withHeader(Header::ACCEPT, 'application/xml'); ``` Authentication helpers set the default `Authorization` header: @@ -92,25 +99,26 @@ $client = $client->withBearerAuth('token'); json('POST', 'https://api.example.com/users', [ +$json = $requestFactory->json(Method::POST, 'https://api.example.com/users', [ 'name' => 'Ada', ]); -$form = $requestFactory->form('POST', 'https://api.example.com/sessions', [ +$form = $requestFactory->form(Method::POST, 'https://api.example.com/sessions', [ 'email' => 'ada@example.com', 'password' => 'secret', ]); -$query = $requestFactory->query('GET', 'https://api.example.com/users?active=1', [ +$query = $requestFactory->query(Method::GET, 'https://api.example.com/users?active=1', [ 'page' => 2, 'search' => 'Ada Lovelace', ]); -$upload = $requestFactory->multipart('POST', 'https://api.example.com/uploads', [ +$upload = $requestFactory->multipart(Method::POST, 'https://api.example.com/uploads', [ 'name' => 'Ada', 'avatar' => Part::file('avatar', '/tmp/avatar.png', 'avatar.png', 'image/png'), ]); @@ -121,11 +129,15 @@ Header overrides are explicit: ```php json('PATCH', 'https://api.example.com/users/1', [ +use Utopia\Psr7\ContentType; +use Utopia\Psr7\Header; +use Utopia\Psr7\Method; + +$request = $requestFactory->json(Method::PATCH, 'https://api.example.com/users/1', [ 'name' => 'Ada', ], [ - 'Accept' => 'application/vnd.api+json', - 'Content-Type' => 'application/merge-patch+json', + Header::ACCEPT => 'application/vnd.api+json', + Header::CONTENT_TYPE => ContentType::MERGE_PATCH_JSON, ]); ``` @@ -191,6 +203,7 @@ The Swoole adapter must run inside a coroutine. use Swoole\Coroutine; use Utopia\Client; use Utopia\Client\Adapter\SwooleCoroutine\Client as SwooleAdapter; +use Utopia\Psr7\Method; use Utopia\Psr7\Request; require __DIR__ . '/vendor/autoload.php'; @@ -206,7 +219,7 @@ Coroutine\run(static function (): void { ); $response = $client->sendRequest( - $requestFactory->query('GET', 'https://example.com', [ + $requestFactory->query(Method::GET, 'https://example.com', [ 'ping' => '1', ]), ); @@ -225,9 +238,34 @@ Both adapters throw PSR-18 exceptions. use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\NetworkExceptionInterface; use Psr\Http\Client\RequestExceptionInterface; +use Utopia\Client\Exception\AdapterPreconditionException; +use Utopia\Client\Exception\ConnectionException; +use Utopia\Client\Exception\DnsException; +use Utopia\Client\Exception\InvalidResponseException; +use Utopia\Client\Exception\InvalidUriException; +use Utopia\Client\Exception\ProtocolException; +use Utopia\Client\Exception\ProxyException; +use Utopia\Client\Exception\TlsException; +use Utopia\Client\Exception\TimeoutException; try { $response = $client->sendRequest($request); +} catch (TimeoutException $error) { + // Transport timeout. +} catch (DnsException $error) { + // DNS resolution failure. +} catch (TlsException $error) { + // TLS handshake or certificate failure. +} catch (ProxyException $error) { + // Proxy transport failure. +} catch (ProtocolException $error) { + // HTTP protocol transport failure. +} catch (ConnectionException $error) { + // Connection refused, reset, unreachable, or broken. +} catch (InvalidResponseException $error) { + // Malformed or invalid HTTP response. +} catch (InvalidUriException | AdapterPreconditionException $error) { + // Request or runtime precondition failure. } catch (NetworkExceptionInterface $error) { // DNS, connection, timeout, or transport failure. } catch (RequestExceptionInterface $error) { diff --git a/src/Client.php b/src/Client.php index fa55961..5b05314 100644 --- a/src/Client.php +++ b/src/Client.php @@ -11,6 +11,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\UriInterface; use Utopia\Client\Adapter; +use Utopia\Psr7\Header; use Utopia\Psr7\Uri; final class Client implements ClientInterface @@ -76,14 +77,14 @@ public function withBaseUri(UriInterface|string $uri): self public function withBasicAuth(string $username, string $password): self { return $this->withHeaders([ - 'Authorization' => 'Basic ' . base64_encode($username . ':' . $password), + Header::AUTHORIZATION => 'Basic ' . base64_encode($username . ':' . $password), ]); } public function withBearerAuth(string $token): self { return $this->withHeaders([ - 'Authorization' => 'Bearer ' . $token, + Header::AUTHORIZATION => 'Bearer ' . $token, ]); } diff --git a/src/Client/Adapter/Curl/Client.php b/src/Client/Adapter/Curl/Client.php index adafb63..92a8c15 100644 --- a/src/Client/Adapter/Curl/Client.php +++ b/src/Client/Adapter/Curl/Client.php @@ -5,15 +5,24 @@ namespace Utopia\Client\Adapter\Curl; use CurlHandle; +use InvalidArgumentException; use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamFactoryInterface; use Utopia\Client\Adapter; +use Utopia\Client\Exception\AdapterInitializationException; +use Utopia\Client\Exception\AdapterPreconditionException; +use Utopia\Client\Exception\ConnectionException; +use Utopia\Client\Exception\DnsException; +use Utopia\Client\Exception\InvalidResponseException; +use Utopia\Client\Exception\InvalidUriException; use Utopia\Client\Exception\NetworkException; -use Utopia\Client\Exception\RequestException; +use Utopia\Client\Exception\ProtocolException; +use Utopia\Client\Exception\ProxyException; use Utopia\Client\Exception\TimeoutException; +use Utopia\Client\Exception\TlsException; use Utopia\Client\Response\Builder as ResponseBuilder; use Utopia\Psr7\Response; use Utopia\Psr7\Stream; @@ -21,6 +30,10 @@ class Client implements Adapter { + private const float DEFAULT_CONNECT_TIMEOUT = 5.0; + + private const float DEFAULT_TIMEOUT = 30.0; + private readonly ResponseBuilder $responseBuilder; /** @@ -33,6 +46,11 @@ public function __construct( StreamFactoryInterface $streamFactory = new Stream\Factory(), private array $options = [], ) { + $this->options += [ + \CURLOPT_CONNECTTIMEOUT_MS => $this->milliseconds(self::DEFAULT_CONNECT_TIMEOUT), + \CURLOPT_TIMEOUT_MS => $this->milliseconds(self::DEFAULT_TIMEOUT), + ]; + $this->responseBuilder = new ResponseBuilder($responseFactory, $streamFactory); } @@ -58,14 +76,14 @@ public function withConnectTimeout(float $seconds): static public function sendRequest(RequestInterface $request): ResponseInterface { if (!\extension_loaded('curl')) { - throw new RequestException($request, 'The curl extension is required.'); + throw new AdapterPreconditionException($request, 'The curl extension is required.'); } $uri = $request->getUri(); $url = (string) $uri; - if ($uri->getScheme() === '' || $uri->getHost() === '') { - throw new RequestException($request, 'Requests must use an absolute URI.'); + if (!\in_array($uri->getScheme(), ['http', 'https'], true) || $uri->getHost() === '') { + throw new InvalidUriException($request, 'Requests must use an absolute URI.'); } $headers = ''; @@ -73,33 +91,36 @@ public function sendRequest(RequestInterface $request): ResponseInterface $handle = curl_init($url); if (!$handle instanceof CurlHandle) { - throw new RequestException($request, 'Unable to initialize curl.'); + throw new AdapterInitializationException($request, 'Unable to initialize curl.'); } $options = $this->options($request, $headers, $body); try { - curl_setopt_array($handle, $options); + if (curl_setopt_array($handle, $options) === false) { + throw new InvalidArgumentException('Unable to configure curl.'); + } + $result = curl_exec($handle); } catch (ValueError $valueError) { - throw new RequestException($request, $valueError->getMessage(), 0, $valueError); + throw new InvalidArgumentException($valueError->getMessage(), 0, $valueError); } if ($result === false) { $message = curl_error($handle); $code = curl_errno($handle); - if ($code === \CURLE_OPERATION_TIMEDOUT) { - throw new TimeoutException($request, $message === '' ? 'Curl request timed out.' : $message, $code); - } + throw $this->networkException($request, $message === '' ? 'Curl request failed.' : $message, $code); + } - throw new NetworkException($request, $message === '' ? 'Curl request failed.' : $message, $code); + if (!preg_match("/\r\n\r\n|\n\n|\r\r/", $headers)) { + throw new ConnectionException($request, 'Connection closed before a complete HTTP response was received.'); } $parsed = $this->parseHeaderBlock($headers); if ($parsed['status'] < 100 || $parsed['status'] > 599) { - throw new RequestException($request, 'Received an invalid HTTP response.'); + throw new InvalidResponseException($request, 'Received an invalid HTTP response.'); } return $this->responseBuilder->build( @@ -158,6 +179,102 @@ private function milliseconds(float $seconds): int return (int) round($seconds * 1000); } + private function networkException(RequestInterface $request, string $message, int $code): NetworkException + { + if ($code === \CURLE_OPERATION_TIMEDOUT) { + return new TimeoutException($request, $message, $code); + } + + if (\in_array($code, $this->curlCodes([ + 'CURLE_COULDNT_RESOLVE_HOST', + 'CURLE_COULDNT_RESOLVE_PROXY', + ]), true)) { + return new DnsException($request, $message, $code); + } + + if (\in_array($code, $this->curlCodes([ + 'CURLE_PROXY', + 'CURLE_HTTP_PROXYTUNNEL', + ]), true)) { + return new ProxyException($request, $message, $code); + } + + if (\in_array($code, $this->curlCodes([ + 'CURLE_SSL_CONNECT_ERROR', + 'CURLE_PEER_FAILED_VERIFICATION', + 'CURLE_SSL_CACERT', + 'CURLE_SSL_PEER_CERTIFICATE', + 'CURLE_SSL_CACERT_BADFILE', + 'CURLE_SSL_CERTPROBLEM', + 'CURLE_SSL_CIPHER', + 'CURLE_SSL_ENGINE_NOTFOUND', + 'CURLE_SSL_ENGINE_SETFAILED', + 'CURLE_SSL_ENGINE_INITFAILED', + 'CURLE_USE_SSL_FAILED', + 'CURLE_SSL_SHUTDOWN_FAILED', + 'CURLE_SSL_CRL_BADFILE', + 'CURLE_SSL_ISSUER_ERROR', + 'CURLE_SSL_PINNEDPUBKEYNOTMATCH', + 'CURLE_SSL_INVALIDCERTSTATUS', + 'CURLE_SSL_CLIENTCERT', + 'CURLE_ECH_REQUIRED', + ]), true)) { + return new TlsException($request, $message, $code); + } + + if (\in_array($code, $this->curlCodes([ + 'CURLE_UNSUPPORTED_PROTOCOL', + 'CURLE_HTTP2', + 'CURLE_HTTP2_STREAM', + 'CURLE_HTTP3', + 'CURLE_QUIC_CONNECT_ERROR', + 'CURLE_WEIRD_SERVER_REPLY', + 'CURLE_BAD_CONTENT_ENCODING', + 'CURLE_CHUNK_FAILED', + 'CURLE_TOO_MANY_REDIRECTS', + 'CURLE_PARTIAL_FILE', + ]), true)) { + return new ProtocolException($request, $message, $code); + } + + if (\in_array($code, $this->curlCodes([ + 'CURLE_COULDNT_CONNECT', + 'CURLE_SEND_ERROR', + 'CURLE_RECV_ERROR', + 'CURLE_GOT_NOTHING', + 'CURLE_INTERFACE_FAILED', + 'CURLE_NO_CONNECTION_AVAILABLE', + 'CURLE_UNRECOVERABLE_POLL', + 'CURLE_AGAIN', + ]), true)) { + return new ConnectionException($request, $message, $code); + } + + return new NetworkException($request, $message, $code); + } + + /** + * @param array $names + * + * @return array + */ + private function curlCodes(array $names): array + { + $codes = []; + + foreach ($names as $name) { + if (\defined($name)) { + $code = \constant($name); + + if (\is_int($code)) { + $codes[] = $code; + } + } + } + + return $codes; + } + /** * @return array */ diff --git a/src/Client/Adapter/SwooleCoroutine/Client.php b/src/Client/Adapter/SwooleCoroutine/Client.php index 7106214..855805f 100644 --- a/src/Client/Adapter/SwooleCoroutine/Client.php +++ b/src/Client/Adapter/SwooleCoroutine/Client.php @@ -4,6 +4,7 @@ namespace Utopia\Client\Adapter\SwooleCoroutine; +use InvalidArgumentException; use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseFactoryInterface; @@ -13,9 +14,17 @@ use Swoole\Coroutine\Http\Client as SwooleClient; use Throwable; use Utopia\Client\Adapter; +use Utopia\Client\Exception\AdapterInitializationException; +use Utopia\Client\Exception\AdapterPreconditionException; +use Utopia\Client\Exception\ConnectionException; +use Utopia\Client\Exception\DnsException; +use Utopia\Client\Exception\InvalidResponseException; +use Utopia\Client\Exception\InvalidUriException; use Utopia\Client\Exception\NetworkException; -use Utopia\Client\Exception\RequestException; +use Utopia\Client\Exception\ProtocolException; +use Utopia\Client\Exception\ProxyException; use Utopia\Client\Exception\TimeoutException; +use Utopia\Client\Exception\TlsException; use Utopia\Client\Response\Builder as ResponseBuilder; use Utopia\Psr7\Response; use Utopia\Psr7\Stream; @@ -23,6 +32,10 @@ class Client implements Adapter { + private const float DEFAULT_CONNECT_TIMEOUT = 5.0; + + private const float DEFAULT_TIMEOUT = 30.0; + private const string SETTING_CONNECT_TIMEOUT = 'connect_timeout'; private const string SETTING_HTTP2 = 'http2'; @@ -39,6 +52,11 @@ public function __construct( StreamFactoryInterface $streamFactory = new Stream\Factory(), private array $settings = [], ) { + $this->settings += [ + self::SETTING_CONNECT_TIMEOUT => self::DEFAULT_CONNECT_TIMEOUT, + self::SETTING_TIMEOUT => self::DEFAULT_TIMEOUT, + ]; + $this->responseBuilder = new ResponseBuilder($responseFactory, $streamFactory); } @@ -64,36 +82,71 @@ public function withConnectTimeout(float $seconds): static public function sendRequest(RequestInterface $request): ResponseInterface { if (!\extension_loaded('swoole')) { - throw new RequestException($request, 'The swoole extension is required.'); + throw new AdapterPreconditionException($request, 'The swoole extension is required.'); } if (Coroutine::getCid() < 0) { - throw new RequestException($request, 'Swoole coroutine HTTP requests must run inside a coroutine.'); + throw new AdapterPreconditionException($request, 'Swoole coroutine HTTP requests must run inside a coroutine.'); } $uri = $request->getUri(); - if ($uri->getScheme() === '' || $uri->getHost() === '') { - throw new RequestException($request, 'Requests must use an absolute URI.'); + if (!\in_array($uri->getScheme(), ['http', 'https'], true) || $uri->getHost() === '') { + throw new InvalidUriException($request, 'Requests must use an absolute URI.'); } - $client = new SwooleClient( - $uri->getHost(), - $this->port($request), - $uri->getScheme() === 'https', - ); + $this->validateSettings($request); + + try { + $client = new SwooleClient( + $uri->getHost(), + $this->port($request), + $uri->getScheme() === 'https', + ); + } catch (Throwable $throwable) { + throw new AdapterInitializationException($request, $throwable->getMessage(), (int) $throwable->getCode(), $throwable); + } + + try { + if ($client->set($this->settings + [ + self::SETTING_HTTP2 => false, + ]) === false) { + throw new InvalidArgumentException('Unable to configure Swoole client settings.'); + } + + if ($client->setMethod($request->getMethod()) === false) { + throw new InvalidArgumentException('Unable to configure Swoole request method.'); + } + + if ($client->setHeaders($this->requestHeaders($request)) === false) { + throw new InvalidArgumentException('Unable to configure Swoole request headers.'); + } + } catch (InvalidArgumentException $invalidArgumentException) { + $client->close(); - $client->set($this->settings + [ - self::SETTING_HTTP2 => false, - ]); + throw $invalidArgumentException; + } catch (Throwable $throwable) { + $client->close(); - $client->setMethod($request->getMethod()); - $client->setHeaders($this->requestHeaders($request)); + throw new InvalidArgumentException($throwable->getMessage(), (int) $throwable->getCode(), $throwable); + } $body = (string) $request->getBody(); if ($body !== '') { - $client->setData($body); + try { + if ($client->setData($body) === false) { + throw new InvalidArgumentException('Unable to configure Swoole request body.'); + } + } catch (InvalidArgumentException $invalidArgumentException) { + $client->close(); + + throw $invalidArgumentException; + } catch (Throwable $throwable) { + $client->close(); + + throw new InvalidArgumentException($throwable->getMessage(), (int) $throwable->getCode(), $throwable); + } } try { @@ -101,7 +154,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface } catch (Throwable $throwable) { $client->close(); - throw new NetworkException($request, $throwable->getMessage(), (int) $throwable->getCode(), $throwable); + throw $this->networkException($request, $throwable->getMessage(), (int) $throwable->getCode(), null, $throwable); } if ($result === false) { @@ -114,7 +167,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface throw new TimeoutException($request, $message, $code); } - throw new NetworkException($request, $message, $code); + throw $this->networkException($request, $message, $code, $statusCode, null, $client->headers); } $statusCode = $client->statusCode; @@ -122,7 +175,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface if (!\is_int($statusCode) || $statusCode < 100 || $statusCode > 599) { $client->close(); - throw new RequestException($request, 'Received an invalid HTTP response.'); + throw new InvalidResponseException($request, 'Received an invalid HTTP response.'); } $headers = $client->headers; @@ -170,6 +223,29 @@ private function seconds(float $seconds): float return $seconds; } + private function validateSettings(RequestInterface $request): void + { + foreach ([self::SETTING_TIMEOUT, self::SETTING_CONNECT_TIMEOUT] as $setting) { + if (!\array_key_exists($setting, $this->settings)) { + continue; + } + + $value = $this->settings[$setting]; + + if (!\is_int($value) && !\is_float($value)) { + throw new InvalidArgumentException('Swoole setting "' . $setting . '" must be a finite number greater than or equal to zero.'); + } + + if ($value < 0 || !is_finite((float) $value)) { + throw new InvalidArgumentException('Swoole setting "' . $setting . '" must be a finite number greater than or equal to zero.'); + } + } + + if (\array_key_exists(self::SETTING_HTTP2, $this->settings) && !\is_bool($this->settings[self::SETTING_HTTP2])) { + throw new InvalidArgumentException('Swoole setting "' . self::SETTING_HTTP2 . '" must be a boolean.'); + } + } + private function path(RequestInterface $request): string { $uri = $request->getUri(); @@ -185,15 +261,137 @@ private function path(RequestInterface $request): string private function isTimeout(string $message, int $code, mixed $statusCode): bool { - if (\is_int($statusCode) && $statusCode === -2) { + unset($message); + + if ($this->statusCodeIs($statusCode, 'SWOOLE_HTTP_CLIENT_ESTATUS_REQUEST_TIMEOUT', -2)) { + return true; + } + + if (\in_array($code, $this->nativeCodes([ + 'SOCKET_ETIMEDOUT', + 'SWOOLE_ERROR_SOCKET_POLL_TIMEOUT', + ], [110]), true)) { return true; } - if ($code === 110) { + return false; + } + + private function networkException(RequestInterface $request, string $message, int $code, mixed $statusCode = null, ?Throwable $previous = null, mixed $headers = null): NetworkException + { + if ($this->isTimeout($message, $code, $statusCode)) { + return new TimeoutException($request, $message, $code, $previous); + } + + if (\in_array($code, $this->nativeCodes([ + 'SWOOLE_ERROR_DNSLOOKUP_RESOLVE_FAILED', + 'SWOOLE_ERROR_DNSLOOKUP_RESOLVE_TIMEOUT', + 'SWOOLE_ERROR_DNSLOOKUP_NO_SERVER', + ]), true)) { + return new DnsException($request, $message, $code, $previous); + } + + if (\in_array($code, $this->nativeCodes([ + 'SWOOLE_ERROR_SSL_NOT_READY', + 'SWOOLE_ERROR_SSL_EMPTY_PEER_CERTIFICATE', + 'SWOOLE_ERROR_SSL_VERIFY_FAILED', + 'SWOOLE_ERROR_SSL_BAD_CLIENT', + 'SWOOLE_ERROR_SSL_BAD_PROTOCOL', + 'SWOOLE_ERROR_SSL_RESET', + 'SWOOLE_ERROR_SSL_HANDSHAKE_FAILED', + 'SWOOLE_ERROR_SSL_CREATE_CONTEXT_FAILED', + 'SWOOLE_ERROR_SSL_CREATE_SESSION_FAILED', + ]), true)) { + return new TlsException($request, $message, $code, $previous); + } + + if (\in_array($code, $this->nativeCodes([ + 'SWOOLE_ERROR_HTTP_PROXY_HANDSHAKE_ERROR', + 'SWOOLE_ERROR_HTTP_PROXY_HANDSHAKE_FAILED', + 'SWOOLE_ERROR_HTTP_PROXY_BAD_RESPONSE', + 'SWOOLE_ERROR_SOCKS5_UNSUPPORT_VERSION', + 'SWOOLE_ERROR_SOCKS5_UNSUPPORT_METHOD', + 'SWOOLE_ERROR_SOCKS5_AUTH_FAILED', + 'SWOOLE_ERROR_SOCKS5_SERVER_ERROR', + 'SWOOLE_ERROR_SOCKS5_HANDSHAKE_FAILED', + ]), true)) { + return new ProxyException($request, $message, $code, $previous); + } + + if (\in_array($code, $this->nativeCodes([ + 'SWOOLE_ERROR_PROTOCOL_ERROR', + 'SWOOLE_ERROR_HTTP_INVALID_PROTOCOL', + 'SWOOLE_ERROR_PACKAGE_MALFORMED_DATA', + 'SWOOLE_ERROR_HTTP2_STREAM_NO_HEADER', + 'SWOOLE_ERROR_HTTP2_SEND_CONTROL_FRAME_FAILED', + 'SWOOLE_ERROR_HTTP2_INTERNAL_ERROR', + ]), true)) { + return new ProtocolException($request, $message, $code, $previous); + } + + if (\is_array($headers) && \in_array($code, $this->nativeCodes([ + 'SOCKET_ECONNRESET', + ], [104]), true)) { + return new ProtocolException($request, $message, $code, $previous); + } + + if (\in_array($code, $this->nativeCodes([ + 'SOCKET_EPIPE', + 'SOCKET_ENETUNREACH', + 'SOCKET_ECONNRESET', + 'SOCKET_ECONNREFUSED', + 'SOCKET_EHOSTUNREACH', + 'SWOOLE_ERROR_CLIENT_NO_CONNECTION', + 'SWOOLE_ERROR_SESSION_CLOSED_BY_SERVER', + 'SWOOLE_ERROR_SESSION_CLOSED_BY_CLIENT', + 'SWOOLE_ERROR_SESSION_CLOSED', + ], [32, 101, 104, 111, 113]), true)) { + return new ConnectionException($request, $message, $code, $previous); + } + + if ($this->statusCodeIs($statusCode, 'SWOOLE_HTTP_CLIENT_ESTATUS_CONNECT_FAILED', -1) || $this->statusCodeIs($statusCode, 'SWOOLE_HTTP_CLIENT_ESTATUS_SERVER_RESET', -3) || $this->statusCodeIs($statusCode, 'SWOOLE_HTTP_CLIENT_ESTATUS_SEND_FAILED', -4)) { + return new ConnectionException($request, $message, $code, $previous); + } + + return new NetworkException($request, $message, $code, $previous); + } + + /** + * @param array $names + * @param array $fallbacks + * + * @return array + */ + private function nativeCodes(array $names, array $fallbacks = []): array + { + $codes = $fallbacks; + + foreach ($names as $name) { + if (!\defined($name)) { + continue; + } + + $code = \constant($name); + + if (\is_int($code)) { + $codes[] = $code; + } + } + + return array_values(array_unique($codes)); + } + + private function statusCodeIs(mixed $statusCode, string $constant, int $fallback): bool + { + if (!\is_int($statusCode)) { + return false; + } + + if (\defined($constant) && \constant($constant) === $statusCode) { return true; } - return str_contains(strtolower($message), 'timeout'); + return $statusCode === $fallback; } /** diff --git a/src/Client/Exception/AdapterInitializationException.php b/src/Client/Exception/AdapterInitializationException.php new file mode 100644 index 0000000..2c46819 --- /dev/null +++ b/src/Client/Exception/AdapterInitializationException.php @@ -0,0 +1,7 @@ +body($method, $uri, json_encode($data, JSON_THROW_ON_ERROR), self::CONTENT_TYPE_JSON, $headers); + $request = $this->body($method, $uri, json_encode($data, JSON_THROW_ON_ERROR), ContentType::JSON, $headers); - if (!$request->hasHeader(self::HEADER_ACCEPT)) { - return $request->withHeader(self::HEADER_ACCEPT, self::CONTENT_TYPE_JSON); + if (!$request->hasHeader(Header::ACCEPT)) { + return $request->withHeader(Header::ACCEPT, ContentType::JSON); } return $request; @@ -66,7 +58,7 @@ public function form(string $method, UriInterface|string $uri, array $data, arra $method, $uri, http_build_query($data, '', '&', PHP_QUERY_RFC3986), - self::CONTENT_TYPE_FORM, + ContentType::FORM_URLENCODED, $headers, ); } @@ -81,8 +73,8 @@ public function body(string $method, UriInterface|string $uri, string $body, str $headers, )->withBody($this->streamFactory->createStream($body)); - if (!$request->hasHeader(self::HEADER_CONTENT_TYPE)) { - return $request->withHeader(self::HEADER_CONTENT_TYPE, $contentType); + if (!$request->hasHeader(Header::CONTENT_TYPE)) { + return $request->withHeader(Header::CONTENT_TYPE, $contentType); } return $request; @@ -125,8 +117,8 @@ public function multipart(string $method, UriInterface|string $uri, array $parts $headers, )->withBody($this->streamFactory->createStream($this->multipartBody($boundary, $parts))); - if (!$request->hasHeader(self::HEADER_CONTENT_TYPE)) { - return $request->withHeader(self::HEADER_CONTENT_TYPE, self::CONTENT_TYPE_MULTIPART . '; boundary=' . $boundary); + if (!$request->hasHeader(Header::CONTENT_TYPE)) { + return $request->withHeader(Header::CONTENT_TYPE, ContentType::MULTIPART_FORM_DATA . '; boundary=' . $boundary); } return $request; @@ -170,15 +162,15 @@ private function multipartBody(string $boundary, array $parts): string private function multipartHeaders(Part $part): string { $headers = [ - 'Content-Disposition' => 'form-data; name="' . $this->escapeQuotedString($part->name()) . '"', + Header::CONTENT_DISPOSITION => 'form-data; name="' . $this->escapeQuotedString($part->name()) . '"', ]; if ($part->filename() !== null) { - $headers['Content-Disposition'] .= '; filename="' . $this->escapeQuotedString($part->filename()) . '"'; + $headers[Header::CONTENT_DISPOSITION] .= '; filename="' . $this->escapeQuotedString($part->filename()) . '"'; } if ($part->contentType() !== null) { - $headers['Content-Type'] = $part->contentType(); + $headers[Header::CONTENT_TYPE] = $part->contentType(); } foreach ($part->headers() as $name => $value) { diff --git a/src/Psr7/Response.php b/src/Psr7/Response.php index 2f37d0d..87ef6ed 100644 --- a/src/Psr7/Response.php +++ b/src/Psr7/Response.php @@ -96,7 +96,7 @@ private function assertStatusCode(int $statusCode): void private function boundary(): string { - $contentType = $this->getHeaderLine('Content-Type'); + $contentType = $this->getHeaderLine(Header::CONTENT_TYPE); if (preg_match('/(?:^|;\s*)boundary=(?:"(?P[^"]+)"|(?P[^;\s]+))/', $contentType, $matches) !== 1) { throw new InvalidArgumentException('Multipart response is missing a boundary.'); diff --git a/src/Psr7/Stream.php b/src/Psr7/Stream.php index ca8e47e..a737bc9 100644 --- a/src/Psr7/Stream.php +++ b/src/Psr7/Stream.php @@ -30,7 +30,9 @@ public function __toString(): string try { $this->seek(0); - return stream_get_contents($this->resource()) ?: ''; + $contents = stream_get_contents($this->resource()); + + return $contents === false ? '' : $contents; } catch (RuntimeException) { return ''; } diff --git a/tests/Client/Adapter/AdapterContract.php b/tests/Client/Adapter/AdapterContract.php new file mode 100644 index 0000000..c1d635d --- /dev/null +++ b/tests/Client/Adapter/AdapterContract.php @@ -0,0 +1,568 @@ + $transportOptions + */ + abstract protected function createAdapter(array $transportOptions = []): Adapter; + + abstract protected function runAdapter(callable $callback): void; + + abstract protected function requireAdapterAvailable(): void; + + /** + * @return array + */ + abstract protected function invalidTransportOptions(): array; + + /** + * @return array + */ + abstract protected function timeoutOptions(float $timeout, ?float $connectTimeout = null): array; + + /** + * @return array + */ + abstract protected function proxyOptions(int $port): array; + + protected function setUp(): void + { + $this->requireAdapterAvailable(); + } + + public function testItRequiresAbsoluteUris(): void + { + $requestFactory = new Request\Factory(); + $client = $this->createAdapter(); + + $this->expectException(InvalidUriException::class); + + $this->send($client, $requestFactory->createRequest(Method::GET, '/relative')); + } + + public function testItRejectsUnsupportedUriSchemes(): void + { + $requestFactory = new Request\Factory(); + $client = $this->createAdapter(); + + $this->expectException(InvalidUriException::class); + + $this->send($client, $requestFactory->createRequest(Method::GET, 'ftp://example.com/resource')); + } + + public function testItSendsRequests(): void + { + Http::serve(function (int $port): void { + $requestFactory = new Request\Factory(); + $streamFactory = new Stream\Factory(); + $client = $this->createAdapter(); + $request = $requestFactory->createRequest(Method::POST, 'http://127.0.0.1:' . $port . '/echo') + ->withHeader(Header::CONTENT_TYPE, ContentType::PLAIN_TEXT) + ->withHeader('X-Custom', 'sent') + ->withBody($streamFactory->createStream('hello')); + + $response = $this->send($client, $request); + + $this->assertSame(202, $response->getStatusCode()); + $this->assertSame('text/plain;charset=UTF-8', $response->getHeaderLine(Header::CONTENT_TYPE)); + $this->assertSame('POST:/echo:sent:hello', (string) $response->getBody()); + }); + } + + public function testItReturnsClientAndServerErrorResponsesWithoutThrowing(): void + { + Http::serve(function (int $port): void { + $requestFactory = new Request\Factory(); + $client = $this->createAdapter(); + + $notFound = $this->send($client, $requestFactory->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/not-found')); + $serverError = $this->send($client, $requestFactory->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/server-error')); + + $this->assertSame(404, $notFound->getStatusCode()); + $this->assertSame('missing', (string) $notFound->getBody()); + $this->assertSame(500, $serverError->getStatusCode()); + $this->assertSame('failed', (string) $serverError->getBody()); + }); + } + + public function testItDoesNotFollowRedirectsByDefault(): void + { + Http::serve(function (int $port): void { + $requestFactory = new Request\Factory(); + $client = $this->createAdapter(); + + $response = $this->send($client, $requestFactory->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/redirect')); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame('/final', $response->getHeaderLine(Header::LOCATION)); + $this->assertSame('redirect', (string) $response->getBody()); + }); + } + + public function testItPreservesDuplicateMixedCaseHeadersAndBinaryBodies(): void + { + Http::serve(function (int $port): void { + $requestFactory = new Request\Factory(); + $client = $this->createAdapter(); + + $headers = $this->send($client, $requestFactory->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/headers')); + $binary = $this->send($client, $requestFactory->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/binary')); + + $this->assertSame(204, $headers->getStatusCode()); + $this->assertSame(['one', 'two'], $headers->getHeader('x-trace')); + $this->assertSame('Value', $headers->getHeaderLine('X-Mixed-Case')); + $this->assertSame(ContentType::OCTET_STREAM, $binary->getHeaderLine(Header::CONTENT_TYPE)); + $this->assertSame("\x00\x01hello\xff", (string) $binary->getBody()); + }); + } + + public function testItSendsExplicitHostAndRepeatedRequestHeaders(): void + { + Http::serve(function (int $port): void { + $request = new Request\Factory() + ->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/request-headers') + ->withHeader(Header::HOST, 'proxy.example.test') + ->withHeader('X-Trace', ['one', 'two']); + $client = $this->createAdapter(); + + $response = $this->send($client, $request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('proxy.example.test:one, two', (string) $response->getBody()); + }); + } + + public function testItSendsDefaultHostWithNonDefaultPorts(): void + { + Http::serve(function (int $port): void { + $request = new Request\Factory() + ->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/request-headers') + ->withHeader('X-Trace', 'sent'); + $client = $this->createAdapter(); + + $response = $this->send($client, $request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('127.0.0.1:' . $port . ':sent', (string) $response->getBody()); + }); + } + + public function testItPreservesQueryStringsEmptyPathsAndStripsFragments(): void + { + Http::serve(function (int $port): void { + $requestFactory = new Request\Factory(); + $client = $this->createAdapter(); + + $query = $this->send($client, $requestFactory->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/request-target?x=1&y=two#fragment')); + $emptyPath = $this->send($client, $requestFactory->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '?ping=1#fragment')); + $encoded = $this->send($client, $requestFactory->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/space%20name?value=a%2Bb#fragment')); + + $this->assertSame('/request-target?x=1&y=two', (string) $query->getBody()); + $this->assertSame('/?ping=1', (string) $emptyPath->getBody()); + $this->assertSame('/space%20name?value=a%2Bb', (string) $encoded->getBody()); + }); + } + + public function testItPreservesMethodsWithEmptyBodies(): void + { + Http::serve(function (int $port): void { + $requestFactory = new Request\Factory(); + $client = $this->createAdapter(); + + $delete = $this->send($client, $requestFactory->createRequest(Method::DELETE, 'http://127.0.0.1:' . $port . '/method')); + $patch = $this->send($client, $requestFactory->createRequest(Method::PATCH, 'http://127.0.0.1:' . $port . '/method')); + $head = $this->send($client, $requestFactory->createRequest(Method::HEAD, 'http://127.0.0.1:' . $port . '/method')); + + $this->assertSame(Method::DELETE, $delete->getHeaderLine('X-Request-Method')); + $this->assertSame(Method::DELETE, (string) $delete->getBody()); + $this->assertSame(Method::PATCH, $patch->getHeaderLine('X-Request-Method')); + $this->assertSame(Method::PATCH, (string) $patch->getBody()); + $this->assertSame(Method::HEAD, $head->getHeaderLine('X-Request-Method')); + $this->assertSame('', (string) $head->getBody()); + }); + } + + public function testItPreservesCustomMethodsAndRequestBodies(): void + { + Http::serve(function (int $port): void { + $streamFactory = new Stream\Factory(); + $request = new Request\Factory() + ->createRequest('PROPFIND', 'http://127.0.0.1:' . $port . '/echo') + ->withHeader('X-Custom', 'sent') + ->withBody($streamFactory->createStream('custom-body')); + $client = $this->createAdapter(); + + $response = $this->send($client, $request); + + $this->assertSame(202, $response->getStatusCode()); + $this->assertSame('PROPFIND:/echo:sent:custom-body', (string) $response->getBody()); + }); + } + + public function testItSendsBinaryRequestBodies(): void + { + Http::serve(function (int $port): void { + $body = "\x00\x01hello\xff"; + $streamFactory = new Stream\Factory(); + $request = new Request\Factory() + ->createRequest(Method::POST, 'http://127.0.0.1:' . $port . '/body-info') + ->withBody($streamFactory->createStream($body)); + $client = $this->createAdapter(); + + $response = $this->send($client, $request); + + $this->assertSame((string) \strlen($body) . ':' . hash('sha256', $body), (string) $response->getBody()); + }); + } + + public function testItPreservesCommaSeparatedAndZeroRequestHeaderValues(): void + { + Http::serve(function (int $port): void { + $request = new Request\Factory() + ->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/selected-headers') + ->withHeader('X-Comma', 'one, two') + ->withHeader('X-Zero', '0') + ->withHeader('X-Mixed-Request', 'Value'); + $client = $this->createAdapter(); + + $response = $this->send($client, $request); + + $this->assertSame('one, two:0:Value', (string) $response->getBody()); + }); + } + + public function testItUsesHttp11ProtocolVersion(): void + { + Http::serve(function (int $port): void { + $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/binary'); + $client = $this->createAdapter(); + + $response = $this->send($client, $request); + + $this->assertSame('1.1', $response->getProtocolVersion()); + }); + } + + public function testItParsesFinalResponseMetadata(): void + { + Http::raw("HTTP/1.1 201 Created Thing\r\nX-Trace: final\r\nX-Colon: http://example.test/a:b\r\nContent-Length: 7\r\n\r\ncreated", function (int $port): void { + $client = $this->createAdapter($this->timeoutOptions(1, 1)); + $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/interim'); + + $response = $this->send($client, $request); + + $this->assertSame(201, $response->getStatusCode()); + $this->assertSame('1.1', $response->getProtocolVersion()); + $this->assertSame('final', $response->getHeaderLine('X-Trace')); + $this->assertSame('http://example.test/a:b', $response->getHeaderLine('X-Colon')); + $this->assertSame('created', (string) $response->getBody()); + }); + } + + public function testItPreservesRepeatedSetCookieResponseHeaders(): void + { + Http::raw("HTTP/1.1 200 OK\r\nSet-Cookie: a=1; Path=/\r\nSet-Cookie: b=2; Path=/; HttpOnly\r\nContent-Length: 2\r\n\r\nok", function (int $port): void { + $client = $this->createAdapter($this->timeoutOptions(1, 1)); + $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/cookies'); + + $response = $this->send($client, $request); + + $this->assertSame(['a=1; Path=/', 'b=2; Path=/; HttpOnly'], $response->getHeader('Set-Cookie')); + $this->assertSame('ok', (string) $response->getBody()); + }); + } + + public function testItDecodesChunkedResponseBodies(): void + { + Http::raw("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n", function (int $port): void { + $client = $this->createAdapter($this->timeoutOptions(1, 1)); + $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/chunked'); + + $response = $this->send($client, $request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('hello world', (string) $response->getBody()); + }); + } + + public function testItReturnsEmptyBodiesForNoContentResponses(): void + { + Http::serve(function (int $port): void { + $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/headers'); + $client = $this->createAdapter(); + + $response = $this->send($client, $request); + + $this->assertSame(204, $response->getStatusCode()); + $this->assertSame('', (string) $response->getBody()); + $this->assertSame(['one', 'two'], $response->getHeader('x-trace')); + }); + } + + public function testItRoundTripsLargeResponseBodies(): void + { + Http::serve(function (int $port): void { + $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/large-response'); + $client = $this->createAdapter(); + + $response = $this->send($client, $request); + $body = (string) $response->getBody(); + + $this->assertSame(262_144, \strlen($body)); + $this->assertSame(hash('sha256', str_repeat('abcd', 65_536)), hash('sha256', $body)); + }); + } + + public function testItRoundTripsLargeRequestBodies(): void + { + Http::serve(function (int $port): void { + $body = str_repeat('wxyz', 65_536); + $streamFactory = new Stream\Factory(); + $request = new Request\Factory() + ->createRequest(Method::POST, 'http://127.0.0.1:' . $port . '/body-info') + ->withBody($streamFactory->createStream($body)); + $client = $this->createAdapter(); + + $response = $this->send($client, $request); + + $this->assertSame((string) \strlen($body) . ':' . hash('sha256', $body), (string) $response->getBody()); + }); + } + + public function testTimeoutHelpersReturnConfiguredClones(): void + { + $client = $this->createAdapter(); + + $this->assertNotSame($client, $client->withTimeout(1)); + $this->assertNotSame($client, $client->withConnectTimeout(1)); + } + + public function testDefaultTimeoutsAllowReasonablySlowResponses(): void + { + Http::serve(function (int $port): void { + $requestFactory = new Request\Factory(); + $client = $this->createAdapter(); + + $response = $this->send($client, $requestFactory->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/slow')); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('slow', (string) $response->getBody()); + }); + } + + public function testItRejectsInvalidResponseStatusCodes(): void + { + Http::raw("HTTP/1.1 999 Invalid\r\nContent-Length: 7\r\n\r\ninvalid", function (int $port): void { + $client = $this->createAdapter($this->timeoutOptions(1, 1)); + $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/invalid'); + + $this->expectException(InvalidResponseException::class); + + $this->send($client, $request); + }); + } + + public function testItThrowsProtocolExceptionsForMalformedResponses(): void + { + Http::raw("not an http response\r\n\r\n", function (int $port): void { + $client = $this->createAdapter($this->timeoutOptions(1, 1)); + $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/malformed'); + + $this->expectException(ProtocolException::class); + + $this->send($client, $request); + }); + } + + public function testItThrowsConnectionExceptionsWhenServerClosesBeforeResponse(): void + { + Http::raw('', function (int $port): void { + $client = $this->createAdapter($this->timeoutOptions(1, 1)); + $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/closed'); + + $this->expectException(ConnectionException::class); + + $this->send($client, $request); + }); + } + + public function testItThrowsConnectionExceptionsForPartialResponseHeaders(): void + { + Http::raw("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n", function (int $port): void { + $client = $this->createAdapter($this->timeoutOptions(1, 1)); + $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/partial-headers'); + + $this->expectException(ConnectionException::class); + + $this->send($client, $request); + }); + } + + public function testItThrowsProtocolExceptionsForTruncatedBodies(): void + { + Http::raw("HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nshort", function (int $port): void { + $client = $this->createAdapter($this->timeoutOptions(1, 1)); + $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/truncated-body'); + + $this->expectException(ProtocolException::class); + + $this->send($client, $request); + }); + } + + public function testItThrowsConnectionExceptionsForConnectionFailures(): void + { + Http::unbound(function (int $port): void { + $requestFactory = new Request\Factory(); + $client = $this->createAdapter($this->timeoutOptions(0.1, 0.1)); + + $this->expectException(ConnectionException::class); + + $this->send($client, $requestFactory->createRequest(Method::GET, 'http://127.0.0.1:' . $port)); + }); + } + + public function testItThrowsDnsExceptionsForResolutionFailures(): void + { + $requestFactory = new Request\Factory(); + $client = $this->createAdapter($this->timeoutOptions(2, 1)); + + $this->expectException(DnsException::class); + + $this->send($client, $requestFactory->createRequest(Method::GET, 'http://utopia-request.invalid')); + } + + public function testItThrowsProxyExceptionsForProxyFailures(): void + { + Http::raw("\x04\x00", function (int $port): void { + $requestFactory = new Request\Factory(); + $client = $this->createAdapter($this->proxyOptions($port) + $this->timeoutOptions(1, 1)); + + $this->expectException(ProxyException::class); + + $this->send($client, $requestFactory->createRequest(Method::GET, 'http://example.com/')); + }); + } + + public function testItThrowsTlsExceptionsForTlsFailures(): void + { + Http::serve(function (int $port): void { + $requestFactory = new Request\Factory(); + $client = $this->createAdapter($this->timeoutOptions(1, 1)); + + $this->expectException(TlsException::class); + + $this->send($client, $requestFactory->createRequest(Method::GET, 'https://127.0.0.1:' . $port . '/binary')); + }); + } + + public function testItThrowsTimeoutExceptionsForTimedOutRequests(): void + { + Http::serve(function (int $port): void { + $requestFactory = new Request\Factory(); + $client = $this->createAdapter($this->timeoutOptions(0.1)); + + $this->expectException(TimeoutException::class); + + $this->send($client, $requestFactory->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/slow')); + }); + } + + public function testItRejectsInvalidTimeoutValues(): void + { + $client = $this->createAdapter(); + + $this->expectException(ValueError::class); + + $client->withTimeout(INF); + } + + public function testItRejectsInvalidConnectTimeoutValues(): void + { + $client = $this->createAdapter(); + + $this->expectException(ValueError::class); + + $client->withConnectTimeout(-0.001); + } + + public function testItRejectsInvalidAdapterConfigurationOptions(): void + { + Http::serve(function (int $port): void { + $client = $this->createAdapter($this->invalidTransportOptions()); + $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/binary'); + + $this->expectException(InvalidArgumentException::class); + + $this->send($client, $request); + }); + } + + public function testItSendsZeroStringBodiesAsNonEmptyBodies(): void + { + Http::serve(function (int $port): void { + $streamFactory = new Stream\Factory(); + $request = new Request\Factory() + ->createRequest(Method::PUT, 'http://127.0.0.1:' . $port . '/echo') + ->withHeader('X-Custom', 'sent') + ->withBody($streamFactory->createStream('0')); + $client = $this->createAdapter(); + + $response = $this->send($client, $request); + + $this->assertSame(202, $response->getStatusCode()); + $this->assertSame('PUT:/echo:sent:0', (string) $response->getBody()); + }); + } + + private function send(Adapter $client, RequestInterface $request): ResponseInterface + { + $response = null; + $thrown = null; + + $this->runAdapter(function () use ($client, $request, &$response, &$thrown): void { + try { + $response = $client->sendRequest($request); + } catch (Throwable $throwable) { + $thrown = $throwable; + } + }); + + if ($thrown instanceof Throwable) { + throw $thrown; + } + + if (!$response instanceof ResponseInterface) { + self::fail('Adapter did not return a response.'); + } + + return $response; + } +} diff --git a/tests/Client/Adapter/Curl/ClientTest.php b/tests/Client/Adapter/Curl/ClientTest.php index c373103..b97654c 100644 --- a/tests/Client/Adapter/Curl/ClientTest.php +++ b/tests/Client/Adapter/Curl/ClientTest.php @@ -4,258 +4,66 @@ namespace Utopia\Tests\Client\Adapter\Curl; -use PHPUnit\Framework\TestCase; -use Psr\Http\Client\ClientExceptionInterface; -use Psr\Http\Client\NetworkExceptionInterface; -use ReflectionMethod; +use Utopia\Client\Adapter; use Utopia\Client\Adapter\Curl\Client; -use Utopia\Client\Exception\TimeoutException; -use Utopia\Psr7\Request; use Utopia\Psr7\Response; use Utopia\Psr7\Stream; +use Utopia\Tests\Client\Adapter\AdapterContract; -final class ClientTest extends TestCase +final class ClientTest extends AdapterContract { - public function testItRequiresAbsoluteUris(): void - { - $requestFactory = new Request\Factory(); - $client = new Client(new Response\Factory(), new Stream\Factory()); - - $this->expectException(ClientExceptionInterface::class); - - $client->sendRequest($requestFactory->createRequest('GET', '/relative')); - } - - public function testItSendsRequestWithCurl(): void - { - if (!\extension_loaded('curl')) { - self::markTestSkipped('The curl extension is not installed.'); - } - - $port = $this->availablePort(); - $server = $this->startServer($port); - - try { - $requestFactory = new Request\Factory(); - $streamFactory = new Stream\Factory(); - $client = new Client(new Response\Factory(), $streamFactory); - $request = $requestFactory->createRequest('POST', 'http://127.0.0.1:' . $port . '/echo') - ->withHeader('Content-Type', 'text/plain') - ->withHeader('X-Custom', 'sent') - ->withBody($streamFactory->createStream('hello')); - $response = $client->sendRequest($request); - - $this->assertSame(202, $response->getStatusCode()); - $this->assertSame('text/plain;charset=UTF-8', $response->getHeaderLine('Content-Type')); - $this->assertSame('POST:/echo:sent:hello', (string) $response->getBody()); - } finally { - proc_terminate($server); - proc_close($server); - } - } - - public function testItReturnsClientAndServerErrorResponsesWithoutThrowing(): void - { - if (!\extension_loaded('curl')) { - self::markTestSkipped('The curl extension is not installed.'); - } - - $port = $this->availablePort(); - $server = $this->startServer($port); - - try { - $requestFactory = new Request\Factory(); - $client = new Client(new Response\Factory(), new Stream\Factory()); - - $notFound = $client->sendRequest($requestFactory->createRequest('GET', 'http://127.0.0.1:' . $port . '/not-found')); - $serverError = $client->sendRequest($requestFactory->createRequest('GET', 'http://127.0.0.1:' . $port . '/server-error')); - - $this->assertSame(404, $notFound->getStatusCode()); - $this->assertSame('missing', (string) $notFound->getBody()); - $this->assertSame(500, $serverError->getStatusCode()); - $this->assertSame('failed', (string) $serverError->getBody()); - } finally { - proc_terminate($server); - proc_close($server); - } - } - - public function testItDoesNotFollowRedirectsByDefault(): void - { - if (!\extension_loaded('curl')) { - self::markTestSkipped('The curl extension is not installed.'); - } - - $port = $this->availablePort(); - $server = $this->startServer($port); - - try { - $requestFactory = new Request\Factory(); - $client = new Client(new Response\Factory(), new Stream\Factory()); - - $response = $client->sendRequest($requestFactory->createRequest('GET', 'http://127.0.0.1:' . $port . '/redirect')); - - $this->assertSame(302, $response->getStatusCode()); - $this->assertSame('/final', $response->getHeaderLine('Location')); - $this->assertSame('redirect', (string) $response->getBody()); - } finally { - proc_terminate($server); - proc_close($server); - } - } - - public function testItPreservesDuplicateMixedCaseHeadersAndBinaryBodies(): void + /** + * @param array $transportOptions + */ + protected function createAdapter(array $transportOptions = []): Adapter { - if (!\extension_loaded('curl')) { - self::markTestSkipped('The curl extension is not installed.'); - } - - $port = $this->availablePort(); - $server = $this->startServer($port); - - try { - $requestFactory = new Request\Factory(); - $client = new Client(new Response\Factory(), new Stream\Factory()); - - $headers = $client->sendRequest($requestFactory->createRequest('GET', 'http://127.0.0.1:' . $port . '/headers')); - $binary = $client->sendRequest($requestFactory->createRequest('GET', 'http://127.0.0.1:' . $port . '/binary')); - - $this->assertSame(204, $headers->getStatusCode()); - $this->assertSame(['one', 'two'], $headers->getHeader('x-trace')); - $this->assertSame('Value', $headers->getHeaderLine('X-Mixed-Case')); - $this->assertSame('application/octet-stream', $binary->getHeaderLine('Content-Type')); - $this->assertSame("\x00\x01hello\xff", (string) $binary->getBody()); - } finally { - proc_terminate($server); - proc_close($server); - } + return new Client(new Response\Factory(), new Stream\Factory(), $transportOptions); } - public function testItThrowsNetworkExceptionsForTransportFailures(): void + protected function runAdapter(callable $callback): void { - if (!\extension_loaded('curl')) { - self::markTestSkipped('The curl extension is not installed.'); - } - - $requestFactory = new Request\Factory(); - $port = $this->availablePort(); - $client = new Client(new Response\Factory(), new Stream\Factory(), [ - \CURLOPT_CONNECTTIMEOUT_MS => 100, - \CURLOPT_TIMEOUT_MS => 100, - ]); - - $this->expectException(NetworkExceptionInterface::class); - - $client->sendRequest($requestFactory->createRequest('GET', 'http://127.0.0.1:' . $port)); + $callback(); } - public function testItThrowsTimeoutExceptionsForTimedOutRequests(): void + protected function requireAdapterAvailable(): void { - if (!\extension_loaded('curl')) { - self::markTestSkipped('The curl extension is not installed.'); - } - - $port = $this->availablePort(); - $server = $this->startServer($port); - - try { - $requestFactory = new Request\Factory(); - $client = new Client(new Response\Factory(), new Stream\Factory(), [ - \CURLOPT_TIMEOUT_MS => 100, - ]); - - $this->expectException(TimeoutException::class); - - $client->sendRequest($requestFactory->createRequest('GET', 'http://127.0.0.1:' . $port . '/slow')); - } finally { - proc_terminate($server); - proc_close($server); - } + $this->assertTrue(\extension_loaded('curl'), 'The curl extension is required.'); } - public function testItParsesTheFinalCurlHeaderBlock(): void + /** + * @return array + */ + protected function invalidTransportOptions(): array { - $client = new Client(new Response\Factory(), new Stream\Factory()); - $method = new ReflectionMethod($client, 'parseHeaderBlock'); - - $parsed = $method->invoke($client, "HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 200\r\nX-Trace: one\r\nX-Trace: two\r\n\r\n"); - - $this->assertSame([ - 'protocol' => '1.1', - 'status' => 200, - 'reason' => '', - 'headers' => [ - 'X-Trace' => ['one', 'two'], - ], - ], $parsed); + return [ + 999_999_999 => true, + ]; } /** - * @return resource + * @return array */ - private function startServer(int $port): mixed + protected function timeoutOptions(float $timeout, ?float $connectTimeout = null): array { - $server = proc_open( - [\PHP_BINARY, '-S', '127.0.0.1:' . $port, \dirname(__DIR__, 3) . '/server.php'], - [ - 0 => ['pipe', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ], - $pipes, - ); + $options = [ + \CURLOPT_TIMEOUT_MS => (int) round($timeout * 1000), + ]; - if (!\is_resource($server)) { - self::fail('Unable to start PHP test server.'); + if ($connectTimeout !== null) { + $options[\CURLOPT_CONNECTTIMEOUT_MS] = (int) round($connectTimeout * 1000); } - unset($pipes); - $this->waitForServer($port); - - return $server; + return $options; } - private function waitForServer(int $port): void - { - $deadline = microtime(true) + 5; - - do { - $connection = @fsockopen('127.0.0.1', $port); - - if (\is_resource($connection)) { - fclose($connection); - - return; - } - - usleep(50_000); - } while (microtime(true) < $deadline); - - self::fail('PHP test server did not start.'); - } - - private function availablePort(): int + /** + * @return array + */ + protected function proxyOptions(int $port): array { - $server = stream_socket_server('tcp://127.0.0.1:0', $errorCode, $errorMessage); - - if (!\is_resource($server)) { - self::fail('Unable to find an available TCP port: ' . $errorCode . ' ' . $errorMessage); - } - - $name = stream_socket_get_name($server, false); - - fclose($server); - - if ($name === false) { - self::fail('Unable to read TCP port.'); - } - - $port = parse_url('tcp://' . $name, PHP_URL_PORT); - - if (!\is_int($port)) { - self::fail('Unable to parse TCP port.'); - } - - return $port; + return [ + \CURLOPT_PROXY => '127.0.0.1:' . $port, + \CURLOPT_PROXYTYPE => \CURLPROXY_SOCKS5, + ]; } } diff --git a/tests/Client/Adapter/SwooleCoroutine/ClientTest.php b/tests/Client/Adapter/SwooleCoroutine/ClientTest.php index cfb1258..4172b5b 100644 --- a/tests/Client/Adapter/SwooleCoroutine/ClientTest.php +++ b/tests/Client/Adapter/SwooleCoroutine/ClientTest.php @@ -4,237 +4,80 @@ namespace Utopia\Tests\Client\Adapter\SwooleCoroutine; -use PHPUnit\Framework\TestCase; -use Psr\Http\Client\ClientExceptionInterface; -use Psr\Http\Client\NetworkExceptionInterface; use Swoole\Coroutine; -use Throwable; +use Utopia\Client\Adapter; use Utopia\Client\Adapter\SwooleCoroutine\Client; -use Utopia\Client\Exception\TimeoutException; +use Utopia\Client\Exception\AdapterPreconditionException; +use Utopia\Psr7\Method; use Utopia\Psr7\Request; use Utopia\Psr7\Response; use Utopia\Psr7\Stream; +use Utopia\Tests\Client\Adapter\AdapterContract; -final class ClientTest extends TestCase +final class ClientTest extends AdapterContract { - public function testItRequiresSwooleExtensionOrCoroutineContext(): void - { - $requestFactory = new Request\Factory(); - $client = new Client(new Response\Factory(), new Stream\Factory()); - - $this->expectException(ClientExceptionInterface::class); - - $client->sendRequest($requestFactory->createRequest('GET', 'https://example.com')); - } - - public function testItSendsRequestsInsideSwooleCoroutines(): void + /** + * @param array $transportOptions + */ + protected function createAdapter(array $transportOptions = []): Adapter { - if (!\extension_loaded('swoole')) { - self::markTestSkipped('The swoole extension is not installed.'); - } - - $port = $this->availablePort(); - $server = $this->startServer($port); - - try { - Coroutine\run(function () use ($port): void { - $requestFactory = new Request\Factory(); - $streamFactory = new Stream\Factory(); - $client = new Client(new Response\Factory(), $streamFactory); - $request = $requestFactory->createRequest('POST', 'http://127.0.0.1:' . $port . '/echo') - ->withHeader('Content-Type', 'text/plain') - ->withHeader('X-Custom', 'sent') - ->withBody($streamFactory->createStream('hello')); - - $response = $client->sendRequest($request); - - $this->assertSame(202, $response->getStatusCode()); - $this->assertSame('text/plain;charset=UTF-8', $response->getHeaderLine('Content-Type')); - $this->assertSame('POST:/echo:sent:hello', (string) $response->getBody()); - }); - } finally { - proc_terminate($server); - proc_close($server); - } + return new Client(new Response\Factory(), new Stream\Factory(), $transportOptions); } - public function testItReturnsErrorResponsesInsideSwooleCoroutinesWithoutThrowing(): void + protected function runAdapter(callable $callback): void { - if (!\extension_loaded('swoole')) { - self::markTestSkipped('The swoole extension is not installed.'); - } - - $port = $this->availablePort(); - $server = $this->startServer($port); - - try { - Coroutine\run(function () use ($port): void { - $requestFactory = new Request\Factory(); - $client = new Client(new Response\Factory(), new Stream\Factory()); - - $notFound = $client->sendRequest($requestFactory->createRequest('GET', 'http://127.0.0.1:' . $port . '/not-found')); - $serverError = $client->sendRequest($requestFactory->createRequest('GET', 'http://127.0.0.1:' . $port . '/server-error')); - - $this->assertSame(404, $notFound->getStatusCode()); - $this->assertSame('missing', (string) $notFound->getBody()); - $this->assertSame(500, $serverError->getStatusCode()); - $this->assertSame('failed', (string) $serverError->getBody()); - }); - } finally { - proc_terminate($server); - proc_close($server); - } + Coroutine\run($callback); } - public function testItNormalizesSwooleResponseHeadersAndBinaryBodies(): void + public function testItRequiresCoroutineContext(): void { - if (!\extension_loaded('swoole')) { - self::markTestSkipped('The swoole extension is not installed.'); - } - - $port = $this->availablePort(); - $server = $this->startServer($port); + $client = $this->createAdapter(); + $request = new Request\Factory()->createRequest(Method::GET, 'https://example.com'); - try { - Coroutine\run(function () use ($port): void { - $requestFactory = new Request\Factory(); - $client = new Client(new Response\Factory(), new Stream\Factory()); + $this->expectException(AdapterPreconditionException::class); - $headers = $client->sendRequest($requestFactory->createRequest('GET', 'http://127.0.0.1:' . $port . '/headers')); - $binary = $client->sendRequest($requestFactory->createRequest('GET', 'http://127.0.0.1:' . $port . '/binary')); - - $this->assertSame(204, $headers->getStatusCode()); - $this->assertSame('Value', $headers->getHeaderLine('X-Mixed-Case')); - $this->assertSame('application/octet-stream', $binary->getHeaderLine('Content-Type')); - $this->assertSame("\x00\x01hello\xff", (string) $binary->getBody()); - }); - } finally { - proc_terminate($server); - proc_close($server); - } + $client->sendRequest($request); } - public function testItThrowsNetworkExceptionsForSwooleTransportFailures(): void + protected function requireAdapterAvailable(): void { - if (!\extension_loaded('swoole')) { - self::markTestSkipped('The swoole extension is not installed.'); - } - - $port = $this->availablePort(); - $thrown = null; - - Coroutine\run(function () use ($port, &$thrown): void { - $requestFactory = new Request\Factory(); - $client = new Client(new Response\Factory(), new Stream\Factory(), [ - 'connect_timeout' => 0.1, - 'timeout' => 0.1, - ]); - - try { - $client->sendRequest($requestFactory->createRequest('GET', 'http://127.0.0.1:' . $port)); - } catch (Throwable $throwable) { - $thrown = $throwable; - } - }); - - $this->assertInstanceOf(NetworkExceptionInterface::class, $thrown); + $this->assertTrue(\extension_loaded('swoole'), 'The swoole extension is required.'); } - public function testItThrowsTimeoutExceptionsForTimedOutRequests(): void + /** + * @return array + */ + protected function invalidTransportOptions(): array { - if (!\extension_loaded('swoole')) { - self::markTestSkipped('The swoole extension is not installed.'); - } - - $port = $this->availablePort(); - $server = $this->startServer($port); - - try { - Coroutine\run(function () use ($port): void { - $requestFactory = new Request\Factory(); - $client = new Client(new Response\Factory(), new Stream\Factory(), [ - 'timeout' => 0.1, - ]); - - try { - $client->sendRequest($requestFactory->createRequest('GET', 'http://127.0.0.1:' . $port . '/slow')); - self::fail('Expected a timeout exception.'); - } catch (Throwable $throwable) { - $this->assertInstanceOf(TimeoutException::class, $throwable); - } - }); - } finally { - proc_terminate($server); - proc_close($server); - } + return [ + 'timeout' => [], + ]; } /** - * @return resource + * @return array */ - private function startServer(int $port): mixed + protected function timeoutOptions(float $timeout, ?float $connectTimeout = null): array { - $server = proc_open( - [\PHP_BINARY, '-S', '127.0.0.1:' . $port, \dirname(__DIR__, 3) . '/server.php'], - [ - 0 => ['pipe', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ], - $pipes, - ); + $options = [ + 'timeout' => $timeout, + ]; - if (!\is_resource($server)) { - self::fail('Unable to start PHP test server.'); + if ($connectTimeout !== null) { + $options['connect_timeout'] = $connectTimeout; } - unset($pipes); - $this->waitForServer($port); - - return $server; - } - - private function waitForServer(int $port): void - { - $deadline = microtime(true) + 5; - - do { - $connection = @fsockopen('127.0.0.1', $port); - - if (\is_resource($connection)) { - fclose($connection); - - return; - } - - usleep(50_000); - } while (microtime(true) < $deadline); - - self::fail('PHP test server did not start.'); + return $options; } - private function availablePort(): int + /** + * @return array + */ + protected function proxyOptions(int $port): array { - $server = stream_socket_server('tcp://127.0.0.1:0', $errorCode, $errorMessage); - - if (!\is_resource($server)) { - self::fail('Unable to find an available TCP port: ' . $errorCode . ' ' . $errorMessage); - } - - $name = stream_socket_get_name($server, false); - - fclose($server); - - if ($name === false) { - self::fail('Unable to read TCP port.'); - } - - $port = parse_url('tcp://' . $name, PHP_URL_PORT); - - if (!\is_int($port)) { - self::fail('Unable to parse TCP port.'); - } - - return $port; + return [ + 'socks5_host' => '127.0.0.1', + 'socks5_port' => $port, + ]; } } diff --git a/tests/Client/Adapter/TimeoutTest.php b/tests/Client/Adapter/TimeoutTest.php index 3a69829..309ae69 100644 --- a/tests/Client/Adapter/TimeoutTest.php +++ b/tests/Client/Adapter/TimeoutTest.php @@ -5,7 +5,6 @@ namespace Utopia\Tests\Client\Adapter; use PHPUnit\Framework\TestCase; -use ReflectionProperty; use Utopia\Client\Adapter\Curl\Client as CurlClient; use Utopia\Client\Adapter\SwooleCoroutine\Client as SwooleClient; use Utopia\Psr7\Response; @@ -14,31 +13,7 @@ final class TimeoutTest extends TestCase { - public function testCurlTimeoutsAreMappedToMillisecondsImmutably(): void - { - $adapter = new CurlClient(new Response\Factory(), new Stream\Factory()); - $configured = $adapter - ->withTimeout(2.5) - ->withConnectTimeout(1.25); - - $this->assertSame([], $this->property($adapter, 'options')); - $this->assertSame(2500, $this->property($configured, 'options')[\CURLOPT_TIMEOUT_MS]); - $this->assertSame(1250, $this->property($configured, 'options')[\CURLOPT_CONNECTTIMEOUT_MS]); - } - - public function testSwooleTimeoutsAreMappedToSettingsImmutably(): void - { - $adapter = new SwooleClient(new Response\Factory(), new Stream\Factory()); - $configured = $adapter - ->withTimeout(2.5) - ->withConnectTimeout(1.25); - - $this->assertSame([], $this->property($adapter, 'settings')); - $this->assertEqualsWithDelta(2.5, $this->property($configured, 'settings')['timeout'], PHP_FLOAT_EPSILON); - $this->assertEqualsWithDelta(1.25, $this->property($configured, 'settings')['connect_timeout'], PHP_FLOAT_EPSILON); - } - - public function testInvalidAdapterTimeoutsAreRejected(): void + public function testCurlTimeoutsRejectInvalidValues(): void { $adapter = new CurlClient(new Response\Factory(), new Stream\Factory()); @@ -47,16 +22,12 @@ public function testInvalidAdapterTimeoutsAreRejected(): void $adapter->withTimeout(\INF); } - /** - * @return array - */ - private function property(object $object, string $property): array + public function testSwooleTimeoutsRejectInvalidValues(): void { - $reflection = new ReflectionProperty($object, $property); - $value = $reflection->getValue($object); + $adapter = new SwooleClient(new Response\Factory(), new Stream\Factory()); - $this->assertIsArray($value); + $this->expectException(ValueError::class); - return $value; + $adapter->withConnectTimeout(-0.001); } } diff --git a/tests/Client/ExceptionTest.php b/tests/Client/ExceptionTest.php new file mode 100644 index 0000000..423ee06 --- /dev/null +++ b/tests/Client/ExceptionTest.php @@ -0,0 +1,44 @@ +assertContains(RequestExceptionInterface::class, class_implements(RequestException::class)); + $this->assertContains(RequestExceptionInterface::class, class_implements(AdapterInitializationException::class)); + $this->assertContains(RequestExceptionInterface::class, class_implements(AdapterPreconditionException::class)); + $this->assertContains(RequestExceptionInterface::class, class_implements(InvalidResponseException::class)); + $this->assertContains(RequestExceptionInterface::class, class_implements(InvalidUriException::class)); + } + + public function testNetworkExceptionsRemainPsrNetworkExceptions(): void + { + $this->assertContains(NetworkExceptionInterface::class, class_implements(ConnectionException::class)); + $this->assertContains(NetworkExceptionInterface::class, class_implements(DnsException::class)); + $this->assertContains(NetworkExceptionInterface::class, class_implements(NetworkException::class)); + $this->assertContains(NetworkExceptionInterface::class, class_implements(ProtocolException::class)); + $this->assertContains(NetworkExceptionInterface::class, class_implements(ProxyException::class)); + $this->assertContains(NetworkExceptionInterface::class, class_implements(TlsException::class)); + $this->assertContains(NetworkExceptionInterface::class, class_implements(TimeoutException::class)); + } +} diff --git a/tests/Psr7/ConstantsTest.php b/tests/Psr7/ConstantsTest.php new file mode 100644 index 0000000..393279c --- /dev/null +++ b/tests/Psr7/ConstantsTest.php @@ -0,0 +1,46 @@ +assertSame('GET', Method::GET); + $this->assertSame('POST', Method::POST); + $this->assertSame('PUT', Method::PUT); + $this->assertSame('PATCH', Method::PATCH); + $this->assertSame('DELETE', Method::DELETE); + $this->assertSame('HEAD', Method::HEAD); + $this->assertSame('OPTIONS', Method::OPTIONS); + $this->assertSame('CONNECT', Method::CONNECT); + $this->assertSame('TRACE', Method::TRACE); + } + + public function testItProvidesCommonHeaderConstants(): void + { + $this->assertSame('Accept', Header::ACCEPT); + $this->assertSame('Authorization', Header::AUTHORIZATION); + $this->assertSame('Content-Disposition', Header::CONTENT_DISPOSITION); + $this->assertSame('Content-Type', Header::CONTENT_TYPE); + $this->assertSame('Host', Header::HOST); + $this->assertSame('User-Agent', Header::USER_AGENT); + } + + public function testItProvidesCommonContentTypeConstants(): void + { + $this->assertSame('application/json', ContentType::JSON); + $this->assertSame('application/merge-patch+json', ContentType::MERGE_PATCH_JSON); + $this->assertSame('application/x-www-form-urlencoded', ContentType::FORM_URLENCODED); + $this->assertSame('multipart/form-data', ContentType::MULTIPART_FORM_DATA); + $this->assertSame('application/octet-stream', ContentType::OCTET_STREAM); + $this->assertSame('text/plain', ContentType::PLAIN_TEXT); + } +} diff --git a/tests/Server/Http.php b/tests/Server/Http.php new file mode 100644 index 0000000..be19a0e --- /dev/null +++ b/tests/Server/Http.php @@ -0,0 +1,204 @@ + ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); + + if (!\is_resource($server)) { + throw new RuntimeException('Unable to start PHP test server.'); + } + + unset($pipes); + self::waitForPort($port); + + return $server; + } + + /** + * @return resource + */ + private static function startRaw(int $port, string $response): mixed + { + $readyFile = tempnam(sys_get_temp_dir(), 'utopia-raw-server-'); + + if ($readyFile === false) { + throw new RuntimeException('Unable to create raw response server readiness file.'); + } + + unlink($readyFile); + + $code = <<<'PHP' +$port = (int) $argv[1]; +$response = base64_decode($argv[2]); +$readyFile = $argv[3]; +$server = stream_socket_server('tcp://127.0.0.1:' . $port, $errorCode, $errorMessage); +if (!is_resource($server)) { + fwrite(STDERR, $errorCode . ' ' . $errorMessage); + exit(1); +} +file_put_contents($readyFile, 'ready'); +$connection = @stream_socket_accept($server, 10); +if (is_resource($connection)) { + fread($connection, 8192); + fwrite($connection, $response); + fclose($connection); +} +fclose($server); +PHP; + + $server = proc_open( + [\PHP_BINARY, '-r', $code, (string) $port, base64_encode($response), $readyFile], + [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); + + if (!\is_resource($server)) { + throw new RuntimeException('Unable to start raw response test server.'); + } + + unset($pipes); + self::waitForReadyFile($readyFile); + + return $server; + } + + /** + * @param resource $server + */ + private static function stop(mixed $server): void + { + proc_terminate($server); + proc_close($server); + } + + private static function waitForPort(int $port): void + { + $deadline = microtime(true) + 5; + + do { + $connection = @fsockopen('127.0.0.1', $port); + + if (\is_resource($connection)) { + fclose($connection); + + return; + } + + usleep(50_000); + } while (microtime(true) < $deadline); + + throw new RuntimeException('PHP test server did not start.'); + } + + private static function waitForReadyFile(string $readyFile): void + { + $deadline = microtime(true) + 5; + + do { + if (is_file($readyFile)) { + unlink($readyFile); + + return; + } + + usleep(50_000); + } while (microtime(true) < $deadline); + + throw new RuntimeException('Raw response test server did not start.'); + } +} diff --git a/tests/server.php b/tests/server.php index badd713..eadf732 100644 --- a/tests/server.php +++ b/tests/server.php @@ -48,6 +48,97 @@ return; } +if ($path === '/request-headers') { + http_response_code(200); + header('Content-Type: text/plain;charset=UTF-8'); + + $host = $_SERVER['HTTP_HOST'] ?? ''; + $host = is_string($host) ? $host : ''; + $trace = $_SERVER['HTTP_X_TRACE'] ?? ''; + $trace = is_string($trace) ? $trace : ''; + + echo $host . ':' . $trace; + + return; +} + +if ($path === '/request-target') { + http_response_code(200); + header('Content-Type: text/plain;charset=UTF-8'); + + echo is_string($requestUri) ? $requestUri : ''; + + return; +} + +if ($path === '/space%20name') { + http_response_code(200); + header('Content-Type: text/plain;charset=UTF-8'); + + echo is_string($requestUri) ? $requestUri : ''; + + return; +} + +if ($path === '/' && is_string($requestUri) && str_contains($requestUri, 'ping=1')) { + http_response_code(200); + header('Content-Type: text/plain;charset=UTF-8'); + + echo $requestUri; + + return; +} + +if ($path === '/method') { + $requestMethod = $_SERVER['REQUEST_METHOD'] ?? ''; + $method = is_string($requestMethod) ? $requestMethod : ''; + + http_response_code(200); + header('Content-Type: text/plain;charset=UTF-8'); + header('X-Request-Method: ' . $method); + + echo $method; + + return; +} + +if ($path === '/body-info') { + $body = file_get_contents('php://input'); + $body = $body === false ? '' : $body; + + http_response_code(200); + header('Content-Type: text/plain;charset=UTF-8'); + + echo strlen($body) . ':' . hash('sha256', $body); + + return; +} + +if ($path === '/selected-headers') { + $comma = $_SERVER['HTTP_X_COMMA'] ?? ''; + $comma = is_string($comma) ? $comma : ''; + $zero = $_SERVER['HTTP_X_ZERO'] ?? ''; + $zero = is_string($zero) ? $zero : ''; + $mixed = $_SERVER['HTTP_X_MIXED_REQUEST'] ?? ''; + $mixed = is_string($mixed) ? $mixed : ''; + + http_response_code(200); + header('Content-Type: text/plain;charset=UTF-8'); + + echo $comma . ':' . $zero . ':' . $mixed; + + return; +} + +if ($path === '/large-response') { + http_response_code(200); + header('Content-Type: text/plain;charset=UTF-8'); + + echo str_repeat('abcd', 65_536); + + return; +} + if ($path === '/slow') { sleep(1); http_response_code(200); From ef8498c710d61c62c8c2ab11921cf0ff45c23959 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:08:48 +0100 Subject: [PATCH 2/5] Satisfy refactor:check and wire ContentType::XML into docs Apply Rector fixes that CI's refactor:check flagged: drop an unused private parameter and simplify control flow in the Swoole adapter, and remove a redundant string cast in the adapter test contract. Use the existing ContentType::XML constant in the README instead of a raw string and cover it in the constants test. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 3 ++- src/Client/Adapter/SwooleCoroutine/Client.php | 12 ++++-------- tests/Client/Adapter/AdapterContract.php | 4 ++-- tests/Psr7/ConstantsTest.php | 1 + 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 3de3753..d24f212 100644 --- a/README.md +++ b/README.md @@ -74,12 +74,13 @@ Configured headers are defaults. If a request already has the same header, the r ```php createRequest(Method::GET, 'users') - ->withHeader(Header::ACCEPT, 'application/xml'); + ->withHeader(Header::ACCEPT, ContentType::XML); ``` Authentication helpers set the default `Authorization` header: diff --git a/src/Client/Adapter/SwooleCoroutine/Client.php b/src/Client/Adapter/SwooleCoroutine/Client.php index 855805f..f69cf70 100644 --- a/src/Client/Adapter/SwooleCoroutine/Client.php +++ b/src/Client/Adapter/SwooleCoroutine/Client.php @@ -95,7 +95,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface throw new InvalidUriException($request, 'Requests must use an absolute URI.'); } - $this->validateSettings($request); + $this->validateSettings(); try { $client = new SwooleClient( @@ -223,7 +223,7 @@ private function seconds(float $seconds): float return $seconds; } - private function validateSettings(RequestInterface $request): void + private function validateSettings(): void { foreach ([self::SETTING_TIMEOUT, self::SETTING_CONNECT_TIMEOUT] as $setting) { if (!\array_key_exists($setting, $this->settings)) { @@ -267,14 +267,10 @@ private function isTimeout(string $message, int $code, mixed $statusCode): bool return true; } - if (\in_array($code, $this->nativeCodes([ + return \in_array($code, $this->nativeCodes([ 'SOCKET_ETIMEDOUT', 'SWOOLE_ERROR_SOCKET_POLL_TIMEOUT', - ], [110]), true)) { - return true; - } - - return false; + ], [110]), true); } private function networkException(RequestInterface $request, string $message, int $code, mixed $statusCode = null, ?Throwable $previous = null, mixed $headers = null): NetworkException diff --git a/tests/Client/Adapter/AdapterContract.php b/tests/Client/Adapter/AdapterContract.php index c1d635d..93df068 100644 --- a/tests/Client/Adapter/AdapterContract.php +++ b/tests/Client/Adapter/AdapterContract.php @@ -238,7 +238,7 @@ public function testItSendsBinaryRequestBodies(): void $response = $this->send($client, $request); - $this->assertSame((string) \strlen($body) . ':' . hash('sha256', $body), (string) $response->getBody()); + $this->assertSame(\strlen($body) . ':' . hash('sha256', $body), (string) $response->getBody()); }); } @@ -352,7 +352,7 @@ public function testItRoundTripsLargeRequestBodies(): void $response = $this->send($client, $request); - $this->assertSame((string) \strlen($body) . ':' . hash('sha256', $body), (string) $response->getBody()); + $this->assertSame(\strlen($body) . ':' . hash('sha256', $body), (string) $response->getBody()); }); } diff --git a/tests/Psr7/ConstantsTest.php b/tests/Psr7/ConstantsTest.php index 393279c..038ff13 100644 --- a/tests/Psr7/ConstantsTest.php +++ b/tests/Psr7/ConstantsTest.php @@ -42,5 +42,6 @@ public function testItProvidesCommonContentTypeConstants(): void $this->assertSame('multipart/form-data', ContentType::MULTIPART_FORM_DATA); $this->assertSame('application/octet-stream', ContentType::OCTET_STREAM); $this->assertSame('text/plain', ContentType::PLAIN_TEXT); + $this->assertSame('application/xml', ContentType::XML); } } From af017806555f8c52c3afe86cdf2bc58a586742da Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:14:00 +0100 Subject: [PATCH 3/5] Install swoole in CI and de-flake the partial-headers curl test CI only loaded ext-curl, so every Swoole adapter test failed its availability guard. Add swoole to shivammathur/setup-php's extensions so the Swoole suite actually runs. The partial-response-headers test sent a Content-Length header, which some libcurl versions parse and then report as CURLE_PARTIAL_FILE (a truncated body, ProtocolException) rather than a dropped connection. Drop the Content-Length so the response is unambiguously incomplete headers and maps to ConnectionException on every curl version. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 2 +- tests/Client/Adapter/AdapterContract.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ce4784..b530e50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} - extensions: curl + extensions: curl, swoole coverage: none - name: Install dependencies diff --git a/tests/Client/Adapter/AdapterContract.php b/tests/Client/Adapter/AdapterContract.php index 93df068..5f414e3 100644 --- a/tests/Client/Adapter/AdapterContract.php +++ b/tests/Client/Adapter/AdapterContract.php @@ -415,7 +415,7 @@ public function testItThrowsConnectionExceptionsWhenServerClosesBeforeResponse() public function testItThrowsConnectionExceptionsForPartialResponseHeaders(): void { - Http::raw("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n", function (int $port): void { + Http::raw("HTTP/1.1 200 OK\r\nX-Partial: value", function (int $port): void { $client = $this->createAdapter($this->timeoutOptions(1, 1)); $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/partial-headers'); From 48b109628687aef5832ceeb771dc469a0d77b353 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:15:39 +0100 Subject: [PATCH 4/5] Pin CI actions to commit SHAs Pin each GitHub Action to the immutable commit SHA of its latest release, with the version recorded in a trailing comment. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b530e50..f078744 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,17 +19,17 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 with: php-version: ${{ matrix.php-version }} extensions: curl, swoole coverage: none - name: Install dependencies - uses: ramsey/composer-install@v3 + uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 - name: Audit dependencies run: composer audit From 23ef58811234860812e8af8e7fdeae70c01c836b Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:19:33 +0100 Subject: [PATCH 5/5] Update Composer dependencies to latest Bump swoole/ide-helper to ^6.0 (5.1.8 => 6.0.2). Other dependencies are already at their newest versions resolvable under the project's PHP >=8.4 floor; PHPUnit stays on 12 since 13 requires PHP >=8.4.1. Co-Authored-By: Claude Opus 4.8 (1M context) --- composer.json | 2 +- composer.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 21d9b88..51750ff 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,7 @@ "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^10.5 || ^11.0 || ^12.0", "rector/rector": "^2.4", - "swoole/ide-helper": "^5.1" + "swoole/ide-helper": "^6.0" }, "config": { "platform": { diff --git a/composer.lock b/composer.lock index 46d26f9..f03e4c4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "928542b2d3e3b157bdd1a3704c405909", + "content-hash": "ab85fbd44a1b47c355d4692a3b096602", "packages": [ { "name": "psr/http-client", @@ -1994,16 +1994,16 @@ }, { "name": "swoole/ide-helper", - "version": "5.1.8", + "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/swoole/ide-helper.git", - "reference": "2806169ec7385e3b5eb484efef1f4764af46d995" + "reference": "6f12243dce071714c5febe059578d909698f9a52" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/swoole/ide-helper/zipball/2806169ec7385e3b5eb484efef1f4764af46d995", - "reference": "2806169ec7385e3b5eb484efef1f4764af46d995", + "url": "https://api.github.com/repos/swoole/ide-helper/zipball/6f12243dce071714c5febe059578d909698f9a52", + "reference": "6f12243dce071714c5febe059578d909698f9a52", "shasum": "" }, "type": "library", @@ -2020,9 +2020,9 @@ "description": "IDE help files for Swoole.", "support": { "issues": "https://github.com/swoole/ide-helper/issues", - "source": "https://github.com/swoole/ide-helper/tree/5.1.8" + "source": "https://github.com/swoole/ide-helper/tree/6.0.2" }, - "time": "2025-08-04T05:40:28+00:00" + "time": "2025-03-23T07:31:41+00:00" }, { "name": "theseer/tokenizer",