diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f7ec70a..897223e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,23 +1,35 @@ name: "Tests" -on: [ pull_request ] +on: [pull_request] jobs: - lint: + tests: name: Tests runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 2 - run: git checkout HEAD^2 - - - name: Install dependencies - run: composer install --profile --ignore-platform-reqs - - - name: Run Tests - run: php -S localhost:8000 tests/router.php & - composer test + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + push: false + tags: fetch-dev + load: true + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Start Server + run: | + docker compose up -d --wait --wait-timeout 30 + + - name: Run Tests + run: docker compose exec -T php vendor/bin/phpunit --configuration phpunit.xml diff --git a/.gitignore b/.gitignore index 84ba485..8e2ce58 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ vendor *.cache composer.lock state.json +.idea diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ea9ea10 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM composer:2.0 AS step0 + +WORKDIR /src/ + +COPY ./composer.json /src/ + +RUN composer update --ignore-platform-reqs --optimize-autoloader \ + --no-plugins --no-scripts --prefer-dist + +FROM appwrite/utopia-base:php-8.4-0.2.1 AS final + +LABEL maintainer="team@utopia.io" + +WORKDIR /code + +COPY --from=step0 /src/vendor /code/vendor + +# Add Source Code +COPY ./src /code/src +COPY ./tests /code/tests +COPY ./phpunit.xml /code/ + +EXPOSE 8000 + +CMD [ "php", "-S", "0.0.0.0:8000", "tests/router.php"] diff --git a/composer.json b/composer.json index cc70203..ebdb47a 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,8 @@ "require-dev": { "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^9.5", - "laravel/pint": "^1.5.0" + "laravel/pint": "^1.5.0", + "swoole/ide-helper": "^6.0" }, "scripts": { "lint": "./vendor/bin/pint --test --config pint.json", @@ -23,4 +24,4 @@ } }, "authors": [] -} \ No newline at end of file +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f67a2b5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + php: + image: fetch-dev + build: + context: . + ports: + - 8000:8000 + volumes: + - ./tests:/code/tests + - ./src:/code/src diff --git a/phpstan.neon b/phpstan.neon index 51e3685..f86533b 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,9 @@ parameters: - level: 8 + level: max paths: - src - - tests \ No newline at end of file + - tests + scanFiles: + - vendor/swoole/ide-helper/src/swoole_library/src/core/Coroutine/functions.php + scanDirectories: + - vendor/swoole/ide-helper/src/swoole \ No newline at end of file diff --git a/src/Adapter.php b/src/Adapter.php new file mode 100644 index 0000000..762e295 --- /dev/null +++ b/src/Adapter.php @@ -0,0 +1,36 @@ + $headers The request headers (formatted as key-value pairs) + * @param RequestOptions $options Request options (timeout, connectTimeout, maxRedirects, allowRedirects, userAgent) + * @param callable|null $chunkCallback Optional callback for streaming chunks + * @return Response The HTTP response + * @throws Exception If the request fails + */ + public function send( + string $url, + string $method, + mixed $body, + array $headers, + RequestOptions $options, + ?callable $chunkCallback = null + ): Response; +} diff --git a/src/Adapter/Curl.php b/src/Adapter/Curl.php new file mode 100644 index 0000000..7f7f8ab --- /dev/null +++ b/src/Adapter/Curl.php @@ -0,0 +1,194 @@ + + */ + private array $config = []; + + /** + * Create a new Curl adapter + * + * @param CurlOptions|null $options Curl adapter options + */ + public function __construct(?CurlOptions $options = null) + { + $options ??= new CurlOptions(); + + $this->config[CURLOPT_SSL_VERIFYPEER] = $options->getSslVerifyPeer(); + $this->config[CURLOPT_SSL_VERIFYHOST] = $options->getSslVerifyHost() ? 2 : 0; + + if ($options->getSslCertificate() !== null) { + $this->config[CURLOPT_SSLCERT] = $options->getSslCertificate(); + } + + if ($options->getSslKey() !== null) { + $this->config[CURLOPT_SSLKEY] = $options->getSslKey(); + } + + if ($options->getCaInfo() !== null) { + $this->config[CURLOPT_CAINFO] = $options->getCaInfo(); + } + + if ($options->getCaPath() !== null) { + $this->config[CURLOPT_CAPATH] = $options->getCaPath(); + } + + if ($options->getProxy() !== null) { + $this->config[CURLOPT_PROXY] = $options->getProxy(); + $this->config[CURLOPT_PROXYTYPE] = $options->getProxyType(); + + if ($options->getProxyUserPwd() !== null) { + $this->config[CURLOPT_PROXYUSERPWD] = $options->getProxyUserPwd(); + } + } + + $this->config[CURLOPT_HTTP_VERSION] = $options->getHttpVersion(); + $this->config[CURLOPT_TCP_KEEPALIVE] = $options->getTcpKeepAlive() ? 1 : 0; + $this->config[CURLOPT_TCP_KEEPIDLE] = $options->getTcpKeepIdle(); + $this->config[CURLOPT_TCP_KEEPINTVL] = $options->getTcpKeepInterval(); + $this->config[CURLOPT_BUFFERSIZE] = $options->getBufferSize(); + $this->config[CURLOPT_VERBOSE] = $options->getVerbose(); + } + + /** + * Get or create the cURL handle + * + * @return CurlHandle + * @throws Exception If cURL initialization fails + */ + private function getHandle(): CurlHandle + { + if ($this->handle === null) { + $handle = curl_init(); + if ($handle === false) { + throw new Exception('Failed to initialize cURL handle'); + } + $this->handle = $handle; + } else { + curl_reset($this->handle); + } + + return $this->handle; + } + + /** + * Send an HTTP request using cURL + * + * @param string $url The URL to send the request to + * @param string $method The HTTP method (GET, POST, etc.) + * @param mixed $body The request body (string, array, or null) + * @param array $headers The request headers (formatted as key-value pairs) + * @param RequestOptions $options Request options (timeout, connectTimeout, maxRedirects, allowRedirects, userAgent) + * @param callable|null $chunkCallback Optional callback for streaming chunks + * @return Response The HTTP response + * @throws Exception If the request fails + */ + public function send( + string $url, + string $method, + mixed $body, + array $headers, + RequestOptions $options, + ?callable $chunkCallback = null + ): Response { + $formattedHeaders = array_map(function ($key, $value) { + return $key . ':' . $value; + }, array_keys($headers), $headers); + + $responseHeaders = []; + $responseBody = ''; + $chunkIndex = 0; + + $ch = $this->getHandle(); + $curlOptions = [ + CURLOPT_URL => $url, + CURLOPT_HTTPHEADER => $formattedHeaders, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HEADERFUNCTION => function ($curl, $header) use (&$responseHeaders) { + $len = strlen($header); + $header = explode(':', $header, 2); + if (count($header) < 2) { + return $len; + } + $responseHeaders[strtolower(trim($header[0]))] = trim($header[1]); + return $len; + }, + CURLOPT_WRITEFUNCTION => function ($ch, $data) use ($chunkCallback, &$responseBody, &$chunkIndex) { + if ($chunkCallback !== null) { + $chunk = new Chunk( + data: $data, + size: strlen($data), + timestamp: microtime(true), + index: $chunkIndex++ + ); + $chunkCallback($chunk); + } else { + $responseBody .= $data; + } + return strlen($data); + }, + CURLOPT_CONNECTTIMEOUT_MS => $options->getConnectTimeout(), + CURLOPT_TIMEOUT_MS => $options->getTimeout(), + CURLOPT_MAXREDIRS => $options->getMaxRedirects(), + CURLOPT_FOLLOWLOCATION => $options->getAllowRedirects(), + CURLOPT_USERAGENT => $options->getUserAgent() + ]; + + if ($body !== null && $body !== [] && $body !== '') { + $curlOptions[CURLOPT_POSTFIELDS] = $body; + } + + // Merge adapter config (adapter config takes precedence) + $curlOptions = $this->config + $curlOptions; + + foreach ($curlOptions as $option => $value) { + curl_setopt($ch, $option, $value); + } + + $success = curl_exec($ch); + if ($success === false) { + $errorMsg = curl_error($ch); + throw new Exception($errorMsg); + } + + $responseStatusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + return new Response( + statusCode: $responseStatusCode, + headers: $responseHeaders, + body: $responseBody + ); + } + + /** + * Close the cURL handle when the adapter is destroyed + */ + public function __destruct() + { + if ($this->handle !== null) { + curl_close($this->handle); + $this->handle = null; + } + } +} diff --git a/src/Adapter/Swoole.php b/src/Adapter/Swoole.php new file mode 100644 index 0000000..627c431 --- /dev/null +++ b/src/Adapter/Swoole.php @@ -0,0 +1,396 @@ + + */ + private array $clients = []; + + /** + * @var array + */ + private array $config = []; + + /** + * Create a new Swoole adapter + * + * @param SwooleOptions|null $options Swoole adapter options + */ + public function __construct(?SwooleOptions $options = null) + { + $options ??= new SwooleOptions(); + + $this->config['keep_alive'] = $options->getKeepAlive(); + $this->config['socket_buffer_size'] = $options->getSocketBufferSize(); + $this->config['http_compression'] = $options->getHttpCompression(); + $this->config['ssl_verify_peer'] = $options->getSslVerifyPeer(); + $this->config['ssl_allow_self_signed'] = $options->getSslAllowSelfSigned(); + $this->config['package_max_length'] = $options->getPackageMaxLength(); + $this->config['websocket_mask'] = $options->getWebsocketMask(); + $this->config['websocket_compression'] = $options->getWebsocketCompression(); + $this->config['lowwater_mark'] = $options->getLowaterMark(); + + if ($options->getSslHostName() !== null) { + $this->config['ssl_host_name'] = $options->getSslHostName(); + } + + if ($options->getSslCafile() !== null) { + $this->config['ssl_cafile'] = $options->getSslCafile(); + } + + if ($options->getBindAddress() !== null) { + $this->config['bind_address'] = $options->getBindAddress(); + } + + if ($options->getBindPort() !== null) { + $this->config['bind_port'] = $options->getBindPort(); + } + } + + /** + * Check if Swoole coroutine client is available + * + * @return bool + */ + public static function isAvailable(): bool + { + return class_exists(CoClient::class); + } + + /** + * Get or create a Swoole HTTP client for the given host/port/ssl configuration + * + * @param string $host + * @param int $port + * @param bool $ssl + * @return CoClient + */ + private function getClient(string $host, int $port, bool $ssl): CoClient + { + $key = "{$host}:{$port}:" . ($ssl ? '1' : '0'); + + if (!isset($this->clients[$key])) { + $this->clients[$key] = new CoClient($host, $port, $ssl); + } + + return $this->clients[$key]; + } + + /** + * Close and remove a client from the cache + * + * @param string $host + * @param int $port + * @param bool $ssl + * @return void + */ + private function closeClient(string $host, int $port, bool $ssl): void + { + $key = "{$host}:{$port}:" . ($ssl ? '1' : '0'); + + if (isset($this->clients[$key])) { + $this->clients[$key]->close(); + unset($this->clients[$key]); + } + } + + /** + * Configure body data on the client + * + * @param CoClient $client Swoole HTTP client + * @param mixed $body + * @param array $headers + * @return void + */ + private function configureBody(CoClient $client, mixed $body, array $headers): void + { + if ($body === null) { + return; + } + + $normalizedHeaders = array_change_key_case($headers, CASE_LOWER); + + if (is_array($body)) { + $hasFiles = false; + $formData = []; + + foreach ($body as $key => $value) { + if ($value instanceof CURLFile || (is_string($value) && str_starts_with($value, '@'))) { + $hasFiles = true; + if ($value instanceof CURLFile) { + $client->addFile( + $value->getFilename(), + $key, + $value->getMimeType() ?: 'application/octet-stream', + $value->getPostFilename() ?: basename($value->getFilename()) + ); + } elseif (str_starts_with($value, '@')) { + $filePath = substr($value, 1); + $client->addFile($filePath, $key); + } + } else { + $formData[$key] = $value; + } + } + + if ($hasFiles) { + foreach ($formData as $key => $value) { + $client->addData($value, $key); + } + } elseif (isset($normalizedHeaders['content-type']) && $normalizedHeaders['content-type'] === 'application/x-www-form-urlencoded') { + $client->setData(http_build_query($body)); + } else { + $client->setData($body); + } + } elseif (is_string($body)) { + $client->setData($body); + } + } + + /** + * Build client settings by merging defaults with custom config + * + * @param float $timeout + * @param float $connectTimeout + * @return array + */ + private function buildClientSettings(float $timeout, float $connectTimeout): array + { + return array_merge($this->config, [ + 'timeout' => $timeout, + 'connect_timeout' => $connectTimeout, + ]); + } + + /** + * Execute the HTTP request + * + * @param string $url + * @param string $method + * @param mixed $body + * @param array $headers + * @param RequestOptions $options + * @param callable|null $chunkCallback + * @return Response + * @throws Exception + */ + private function executeRequest( + string $url, + string $method, + mixed $body, + array $headers, + RequestOptions $options, + ?callable $chunkCallback + ): Response { + if (!preg_match('~^https?://~i', $url)) { + $url = 'http://' . $url; + } + + $parsedUrl = parse_url($url); + if ($parsedUrl === false) { + throw new Exception('Invalid URL'); + } + + $host = $parsedUrl['host'] ?? 'localhost'; + $port = $parsedUrl['port'] ?? (isset($parsedUrl['scheme']) && $parsedUrl['scheme'] === 'https' ? 443 : 80); + $path = $parsedUrl['path'] ?? '/'; + $query = $parsedUrl['query'] ?? ''; + $ssl = ($parsedUrl['scheme'] ?? 'http') === 'https'; + + if ($ssl && $port === 80) { + $port = 443; + } + + if ($query !== '') { + $path .= '?' . $query; + } + + $client = $this->getClient($host, $port, $ssl); + + $timeout = $options->getTimeout() / 1000; + $connectTimeout = $options->getConnectTimeout() / 1000; + $maxRedirects = $options->getMaxRedirects(); + $allowRedirects = $options->getAllowRedirects(); + $userAgent = $options->getUserAgent(); + + $client->set($this->buildClientSettings($timeout, $connectTimeout)); + + $client->setMethod($method); + + $allHeaders = $headers; + if ($userAgent !== '') { + $allHeaders['User-Agent'] = $userAgent; + } + + $client->setHeaders($allHeaders); + + $this->configureBody($client, $body, $headers); + + $responseBody = ''; + $chunkIndex = 0; + + $redirectCount = 0; + do { + $success = $client->execute($path); + + if (!$success) { + $errorCode = $client->errCode; + $errorMsg = $client->errMsg; + $this->closeClient($host, $port, $ssl); + throw new Exception("Request failed: {$errorMsg} (Code: {$errorCode})"); + } + + $currentResponseBody = $client->body ?? ''; + + if ($chunkCallback !== null && !empty($currentResponseBody)) { + $chunk = new Chunk( + data: $currentResponseBody, + size: strlen($currentResponseBody), + timestamp: microtime(true), + index: $chunkIndex++ + ); + $chunkCallback($chunk); + } + $responseBody = $currentResponseBody; + + $statusCode = $client->getStatusCode(); + + if ($allowRedirects && in_array($statusCode, self::REDIRECT_STATUS_CODES) && $redirectCount < $maxRedirects) { + $location = $client->headers['location'] ?? $client->headers['Location'] ?? null; + if ($location !== null) { + $redirectCount++; + if (strpos($location, 'http') === 0) { + $parsedLocation = parse_url($location); + $newHost = $parsedLocation['host'] ?? $host; + $newPort = $parsedLocation['port'] ?? (isset($parsedLocation['scheme']) && $parsedLocation['scheme'] === 'https' ? 443 : 80); + $newSsl = ($parsedLocation['scheme'] ?? 'http') === 'https'; + $path = ($parsedLocation['path'] ?? '/') . (isset($parsedLocation['query']) ? '?' . $parsedLocation['query'] : ''); + + if ($newHost !== $host || $newPort !== $port || $newSsl !== $ssl) { + $host = $newHost; + $port = $newPort; + $ssl = $newSsl; + $client = $this->getClient($host, $port, $ssl); + $client->set($this->buildClientSettings($timeout, $connectTimeout)); + $client->setMethod($method); + + // Filter sensitive headers on cross-origin redirects + $redirectHeaders = array_filter( + $allHeaders, + fn ($key) => !in_array(strtolower($key), self::SENSITIVE_HEADERS), + ARRAY_FILTER_USE_KEY + ); + + $client->setHeaders($redirectHeaders); + $this->configureBody($client, $body, $headers); + } + } else { + $path = $location; + } + continue; + } + } + + break; + } while (true); + + $responseHeaders = array_change_key_case($client->headers ?? [], CASE_LOWER); + $statusCode = $client->getStatusCode(); + $responseStatusCode = is_int($statusCode) ? $statusCode : 0; + + return new Response( + statusCode: $responseStatusCode, + headers: $responseHeaders, + body: $responseBody + ); + } + + /** + * Send an HTTP request using Swoole + * + * @param string $url The URL to send the request to + * @param string $method The HTTP method (GET, POST, etc.) + * @param mixed $body The request body (string, array, or null) + * @param array $headers The request headers (formatted as key-value pairs) + * @param RequestOptions $options Request options (timeout, connectTimeout, maxRedirects, allowRedirects, userAgent) + * @param callable|null $chunkCallback Optional callback for streaming chunks + * @return Response The HTTP response + * @throws Exception If the request fails or Swoole is not available + */ + public function send( + string $url, + string $method, + mixed $body, + array $headers, + RequestOptions $options, + ?callable $chunkCallback = null + ): Response { + if (!self::isAvailable()) { + throw new Exception('Swoole extension is not installed'); + } + + // If already in a coroutine, execute directly + if (Coroutine::getCid() > 0) { + return $this->executeRequest($url, $method, $body, $headers, $options, $chunkCallback); + } + + // Wrap in coroutine scheduler + $response = null; + $exception = null; + + run(function () use ($url, $method, $body, $headers, $options, $chunkCallback, &$response, &$exception) { + try { + $response = $this->executeRequest($url, $method, $body, $headers, $options, $chunkCallback); + } catch (Throwable $e) { + $exception = $e; + } + }); + + if ($exception !== null) { + throw new Exception($exception->getMessage()); + } + + if ($response === null) { + throw new Exception('Failed to get response'); + } + + return $response; + } + + /** + * Close all cached clients when the adapter is destroyed + */ + public function __destruct() + { + foreach ($this->clients as $client) { + $client->close(); + } + $this->clients = []; + } +} diff --git a/src/Chunk.php b/src/Chunk.php index a644190..4901765 100644 --- a/src/Chunk.php +++ b/src/Chunk.php @@ -1,5 +1,7 @@ headers */ private array $headers = []; private int $timeout = 15000; // milliseconds (15 seconds) - private int $connectTimeout = 60000; // milliseconds (60 seconds) + private int $connectTimeout = 5000; // milliseconds (5 seconds) private int $maxRedirects = 5; private bool $allowRedirects = true; private string $userAgent = ''; @@ -35,7 +40,18 @@ class Client /** @var array $retryStatusCodes */ private array $retryStatusCodes = [500, 503]; - private mixed $jsonEncodeFlags; + private ?int $jsonEncodeFlags = null; + private Adapter $adapter; + + /** + * Client constructor + * + * @param Adapter|null $adapter HTTP adapter to use (defaults to Curl) + */ + public function __construct(?Adapter $adapter = null) + { + $this->adapter = $adapter ?? new Curl(); + } /** * @param string $key @@ -153,7 +169,9 @@ public function setMaxRetries(int $maxRetries): self */ public function setJsonEncodeFlags(array $flags): self { - $this->jsonEncodeFlags = implode('|', $flags); + $this->jsonEncodeFlags = array_reduce($flags, function ($carry, $flag) { + return $carry | $flag; + }, 0); return $this; } @@ -279,84 +297,38 @@ public function fetch( $body = match ($this->headers['content-type']) { self::CONTENT_TYPE_APPLICATION_JSON => $this->jsonEncode($body), self::CONTENT_TYPE_APPLICATION_FORM_URLENCODED, self::CONTENT_TYPE_MULTIPART_FORM_DATA => self::flatten($body), - self::CONTENT_TYPE_GRAPHQL => $body[0], + self::CONTENT_TYPE_GRAPHQL => isset($body['query']) && is_string($body['query']) ? $body['query'] : $this->jsonEncode($body), default => $body, }; } - $formattedHeaders = array_map(function ($key, $value) { - return $key . ':' . $value; - }, array_keys($this->headers), $this->headers); - if ($query) { - $url = rtrim($url, '?') . '?' . http_build_query($query); - } - - $responseHeaders = []; - $responseBody = ''; - $chunkIndex = 0; - $ch = curl_init(); - $curlOptions = [ - CURLOPT_URL => $url, - CURLOPT_HTTPHEADER => $formattedHeaders, - CURLOPT_CUSTOMREQUEST => $method, - CURLOPT_POSTFIELDS => $body, - CURLOPT_HEADERFUNCTION => function ($curl, $header) use (&$responseHeaders) { - $len = strlen($header); - $header = explode(':', $header, 2); - if (count($header) < 2) { // ignore invalid headers - return $len; - } - $responseHeaders[strtolower(trim($header[0]))] = trim($header[1]); - return $len; - }, - CURLOPT_WRITEFUNCTION => function ($ch, $data) use ($chunks, &$responseBody, &$chunkIndex) { - if ($chunks !== null) { - $chunk = new Chunk( - data: $data, - size: strlen($data), - timestamp: microtime(true), - index: $chunkIndex++ - ); - $chunks($chunk); - } else { - $responseBody .= $data; - } - return strlen($data); - }, - CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs ?? $this->connectTimeout, - CURLOPT_TIMEOUT_MS => $timeoutMs ?? $this->timeout, - CURLOPT_MAXREDIRS => $this->maxRedirects, - CURLOPT_FOLLOWLOCATION => $this->allowRedirects, - CURLOPT_USERAGENT => $this->userAgent - ]; - - // Merge user-defined CURL options with defaults - foreach ($curlOptions as $option => $value) { - curl_setopt($ch, $option, $value); + $url = rtrim($url, '?&'); + $separator = str_contains($url, '?') ? '&' : '?'; + $url = $url . $separator . http_build_query($query); } - $sendRequest = function () use ($ch, &$responseHeaders, &$responseBody) { - $responseHeaders = []; - - $success = curl_exec($ch); - if ($success === false) { - $errorMsg = curl_error($ch); - curl_close($ch); - throw new Exception($errorMsg); - } - - $responseStatusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - return new Response( - statusCode: $responseStatusCode, - headers: $responseHeaders, - body: $responseBody + $options = new RequestOptions( + timeout: $timeoutMs ?? $this->timeout, + connectTimeout: $connectTimeoutMs ?? $this->connectTimeout, + maxRedirects: $this->maxRedirects, + allowRedirects: $this->allowRedirects, + userAgent: $this->userAgent + ); + + $sendRequest = function () use ($url, $method, $body, $options, $chunks): Response { + return $this->adapter->send( + url: $url, + method: $method, + body: $body, + headers: $this->headers, + options: $options, + chunkCallback: $chunks ); }; if ($this->maxRetries > 0) { + /** @var Response $response */ $response = $this->withRetries($sendRequest); } else { $response = $sendRequest(); diff --git a/src/Exception.php b/src/Exception.php index 453bc9c..12b608a 100644 --- a/src/Exception.php +++ b/src/Exception.php @@ -1,5 +1,7 @@ sslVerifyPeer; + } + + public function getSslVerifyHost(): bool + { + return $this->sslVerifyHost; + } + + public function getSslCertificate(): ?string + { + return $this->sslCertificate; + } + + public function getSslKey(): ?string + { + return $this->sslKey; + } + + public function getCaInfo(): ?string + { + return $this->caInfo; + } + + public function getCaPath(): ?string + { + return $this->caPath; + } + + public function getProxy(): ?string + { + return $this->proxy; + } + + public function getProxyUserPwd(): ?string + { + return $this->proxyUserPwd; + } + + public function getProxyType(): int + { + return $this->proxyType; + } + + public function getHttpVersion(): int + { + return $this->httpVersion; + } + + public function getTcpKeepAlive(): bool + { + return $this->tcpKeepAlive; + } + + public function getTcpKeepIdle(): int + { + return $this->tcpKeepIdle; + } + + public function getTcpKeepInterval(): int + { + return $this->tcpKeepInterval; + } + + public function getBufferSize(): int + { + return $this->bufferSize; + } + + public function getVerbose(): bool + { + return $this->verbose; + } +} diff --git a/src/Options/Request.php b/src/Options/Request.php new file mode 100644 index 0000000..191250a --- /dev/null +++ b/src/Options/Request.php @@ -0,0 +1,71 @@ +timeout; + } + + /** + * Get connection timeout in milliseconds + */ + public function getConnectTimeout(): int + { + return $this->connectTimeout; + } + + /** + * Get maximum number of redirects to follow + */ + public function getMaxRedirects(): int + { + return $this->maxRedirects; + } + + /** + * Get whether to follow redirects + */ + public function getAllowRedirects(): bool + { + return $this->allowRedirects; + } + + /** + * Get user agent string + */ + public function getUserAgent(): string + { + return $this->userAgent; + } +} diff --git a/src/Options/Swoole.php b/src/Options/Swoole.php new file mode 100644 index 0000000..92dc3b7 --- /dev/null +++ b/src/Options/Swoole.php @@ -0,0 +1,112 @@ +keepAlive; + } + + public function getSocketBufferSize(): int + { + return $this->socketBufferSize; + } + + public function getHttpCompression(): bool + { + return $this->httpCompression; + } + + public function getSslVerifyPeer(): bool + { + return $this->sslVerifyPeer; + } + + public function getSslHostName(): ?string + { + return $this->sslHostName; + } + + public function getSslCafile(): ?string + { + return $this->sslCafile; + } + + public function getSslAllowSelfSigned(): bool + { + return $this->sslAllowSelfSigned; + } + + public function getPackageMaxLength(): int + { + return $this->packageMaxLength; + } + + public function getWebsocketMask(): bool + { + return $this->websocketMask; + } + + public function getBindAddress(): ?string + { + return $this->bindAddress; + } + + public function getBindPort(): ?int + { + return $this->bindPort; + } + + public function getWebsocketCompression(): bool + { + return $this->websocketCompression; + } + + public function getLowaterMark(): int + { + return $this->lowaterMark; + } +} diff --git a/src/Response.php b/src/Response.php index 54a79bb..048a761 100644 --- a/src/Response.php +++ b/src/Response.php @@ -80,19 +80,36 @@ public function getStatusCode(): int */ public function text(): string { - return \strval($this->body); + if ($this->body === null) { + return ''; + } + if (is_string($this->body)) { + return $this->body; + } + if (is_scalar($this->body)) { + return \strval($this->body); + } + if (is_object($this->body) && method_exists($this->body, '__toString')) { + return (string) $this->body; + } + return ''; } /** * This method is used to convert the response body to JSON * @return mixed + * @throws Exception If JSON decoding fails */ public function json(): mixed { - $data = \json_decode($this->body, true); - if ($data === null) { // Throw an exception if the data is null - throw new \Exception('Error decoding JSON'); + $bodyString = is_string($this->body) ? $this->body : ''; + $data = \json_decode($bodyString, true); + + // Check for JSON errors using json_last_error() + if (\json_last_error() !== JSON_ERROR_NONE) { + throw new Exception('Error decoding JSON: ' . \json_last_error_msg()); } + return $data; } @@ -102,10 +119,11 @@ public function json(): mixed */ public function blob(): string { - $bin = ""; - for ($i = 0, $j = strlen($this->body); $i < $j; $i++) { - $bin .= decbin(ord($this->body)) . " "; + $bodyString = is_string($this->body) ? $this->body : ''; + $bin = []; + for ($i = 0, $j = strlen($bodyString); $i < $j; $i++) { + $bin[] = decbin(ord($bodyString[$i])); } - return $bin; + return implode(" ", $bin); } } diff --git a/tests/Adapter/CurlTest.php b/tests/Adapter/CurlTest.php new file mode 100644 index 0000000..dcb4d11 --- /dev/null +++ b/tests/Adapter/CurlTest.php @@ -0,0 +1,230 @@ +adapter = new Curl(); + } + + /** + * Test basic GET request + */ + public function testGetRequest(): void + { + $response = $this->adapter->send( + url: '127.0.0.1:8000', + method: 'GET', + body: null, + headers: [], + options: new RequestOptions() + ); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $data = $response->json(); + $this->assertIsArray($data); + $this->assertSame('GET', $data['method']); + } + + /** + * Test POST request with JSON body + */ + public function testPostWithJsonBody(): void + { + $body = json_encode(['name' => 'John Doe', 'age' => 30]); + $response = $this->adapter->send( + url: '127.0.0.1:8000', + method: 'POST', + body: $body, + headers: ['content-type' => 'application/json'], + options: new RequestOptions() + ); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $data = $response->json(); + $this->assertIsArray($data); + $this->assertSame('POST', $data['method']); + $this->assertSame($body, $data['body']); + } + + /** + * Test request with custom timeout + */ + public function testCustomTimeout(): void + { + $response = $this->adapter->send( + url: '127.0.0.1:8000', + method: 'GET', + body: null, + headers: [], + options: new RequestOptions( + timeout: 5000, + connectTimeout: 10000, + userAgent: 'TestAgent/1.0' + ) + ); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + } + + /** + * Test redirect handling + */ + public function testRedirectHandling(): void + { + $response = $this->adapter->send( + url: '127.0.0.1:8000/redirect', + method: 'GET', + body: null, + headers: [], + options: new RequestOptions() + ); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $data = $response->json(); + $this->assertIsArray($data); + $this->assertSame('redirectedPage', $data['page']); + } + + /** + * Test redirect disabled + */ + public function testRedirectDisabled(): void + { + $response = $this->adapter->send( + url: '127.0.0.1:8000/redirect', + method: 'GET', + body: null, + headers: [], + options: new RequestOptions( + maxRedirects: 0, + allowRedirects: false + ) + ); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(302, $response->getStatusCode()); + } + + /** + * Test chunk callback + */ + public function testChunkCallback(): void + { + $chunks = []; + $response = $this->adapter->send( + url: '127.0.0.1:8000/chunked', + method: 'GET', + body: null, + headers: [], + options: new RequestOptions(), + chunkCallback: function (Chunk $chunk) use (&$chunks) { + $chunks[] = $chunk; + } + ); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertGreaterThan(0, count($chunks)); + + foreach ($chunks as $index => $chunk) { + $this->assertInstanceOf(Chunk::class, $chunk); + $this->assertSame($index, $chunk->getIndex()); + $this->assertGreaterThan(0, $chunk->getSize()); + $this->assertNotEmpty($chunk->getData()); + } + } + + /** + * Test form data body + */ + public function testFormDataBody(): void + { + $body = ['name' => 'John Doe', 'age' => '30']; + $response = $this->adapter->send( + url: '127.0.0.1:8000', + method: 'POST', + body: $body, + headers: ['content-type' => 'application/x-www-form-urlencoded'], + options: new RequestOptions() + ); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + } + + /** + * Test response headers + */ + public function testResponseHeaders(): void + { + $response = $this->adapter->send( + url: '127.0.0.1:8000', + method: 'GET', + body: null, + headers: [], + options: new RequestOptions() + ); + + $headers = $response->getHeaders(); + $this->assertIsArray($headers); + $this->assertArrayHasKey('content-type', $headers); + } + + /** + * Test invalid URL throws exception + */ + public function testInvalidUrlThrowsException(): void + { + $this->expectException(Exception::class); + $this->adapter->send( + url: 'invalid://url', + method: 'GET', + body: null, + headers: [], + options: new RequestOptions() + ); + } + + /** + * Test file upload with CURLFile + */ + public function testFileUpload(): void + { + $filePath = __DIR__ . '/../resources/logo.png'; + $body = [ + 'file' => new \CURLFile(strval(realpath($filePath)), 'image/png', 'logo.png') + ]; + + $response = $this->adapter->send( + url: '127.0.0.1:8000', + method: 'POST', + body: $body, + headers: ['content-type' => 'multipart/form-data'], + options: new RequestOptions() + ); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $data = $response->json(); + $this->assertIsArray($data); + $this->assertIsString($data['files']); + $files = json_decode($data['files'], true); + $this->assertIsArray($files); + $this->assertSame('logo.png', $files['file']['name']); + } +} diff --git a/tests/Adapter/SwooleTest.php b/tests/Adapter/SwooleTest.php new file mode 100644 index 0000000..a513659 --- /dev/null +++ b/tests/Adapter/SwooleTest.php @@ -0,0 +1,234 @@ +markTestSkipped('Swoole extension is not installed'); + } + $this->adapter = new Swoole(); + } + + /** + * Test basic GET request + */ + public function testGetRequest(): void + { + if ($this->adapter === null) { + $this->markTestSkipped('Swoole extension is not installed'); + } + + $response = $this->adapter->send( + url: '127.0.0.1:8000', + method: 'GET', + body: null, + headers: [], + options: new RequestOptions() + ); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $data = $response->json(); + $this->assertIsArray($data); + $this->assertSame('GET', $data['method']); + } + + /** + * Test POST request with JSON body + */ + public function testPostWithJsonBody(): void + { + if ($this->adapter === null) { + $this->markTestSkipped('Swoole extension is not installed'); + } + + $body = json_encode(['name' => 'John Doe', 'age' => 30]); + $response = $this->adapter->send( + url: '127.0.0.1:8000', + method: 'POST', + body: $body, + headers: ['content-type' => 'application/json'], + options: new RequestOptions() + ); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $data = $response->json(); + $this->assertIsArray($data); + $this->assertSame('POST', $data['method']); + $this->assertSame($body, $data['body']); + } + + /** + * Test request with custom timeout + */ + public function testCustomTimeout(): void + { + if ($this->adapter === null) { + $this->markTestSkipped('Swoole extension is not installed'); + } + + $response = $this->adapter->send( + url: '127.0.0.1:8000', + method: 'GET', + body: null, + headers: [], + options: new RequestOptions( + timeout: 5000, + connectTimeout: 10000, + userAgent: 'TestAgent/1.0' + ) + ); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + } + + /** + * Test redirect handling + */ + public function testRedirectHandling(): void + { + if ($this->adapter === null) { + $this->markTestSkipped('Swoole extension is not installed'); + } + + $response = $this->adapter->send( + url: '127.0.0.1:8000/redirect', + method: 'GET', + body: null, + headers: [], + options: new RequestOptions() + ); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $data = $response->json(); + $this->assertIsArray($data); + $this->assertSame('redirectedPage', $data['page']); + } + + /** + * Test redirect disabled + */ + public function testRedirectDisabled(): void + { + if ($this->adapter === null) { + $this->markTestSkipped('Swoole extension is not installed'); + } + + $response = $this->adapter->send( + url: '127.0.0.1:8000/redirect', + method: 'GET', + body: null, + headers: [], + options: new RequestOptions( + maxRedirects: 0, + allowRedirects: false + ) + ); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(302, $response->getStatusCode()); + } + + /** + * Test chunk callback + */ + public function testChunkCallback(): void + { + if ($this->adapter === null) { + $this->markTestSkipped('Swoole extension is not installed'); + } + + $chunks = []; + $response = $this->adapter->send( + url: '127.0.0.1:8000/chunked', + method: 'GET', + body: null, + headers: [], + options: new RequestOptions(), + chunkCallback: function (Chunk $chunk) use (&$chunks) { + $chunks[] = $chunk; + } + ); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertGreaterThan(0, count($chunks)); + + foreach ($chunks as $index => $chunk) { + $this->assertInstanceOf(Chunk::class, $chunk); + $this->assertSame($index, $chunk->getIndex()); + $this->assertGreaterThan(0, $chunk->getSize()); + $this->assertNotEmpty($chunk->getData()); + } + } + + /** + * Test form data body + */ + public function testFormDataBody(): void + { + if ($this->adapter === null) { + $this->markTestSkipped('Swoole extension is not installed'); + } + + $body = ['name' => 'John Doe', 'age' => '30']; + $response = $this->adapter->send( + url: '127.0.0.1:8000', + method: 'POST', + body: $body, + headers: ['content-type' => 'application/x-www-form-urlencoded'], + options: new RequestOptions() + ); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + } + + /** + * Test response headers + */ + public function testResponseHeaders(): void + { + if ($this->adapter === null) { + $this->markTestSkipped('Swoole extension is not installed'); + } + + $response = $this->adapter->send( + url: '127.0.0.1:8000', + method: 'GET', + body: null, + headers: [], + options: new RequestOptions() + ); + + $headers = $response->getHeaders(); + $this->assertIsArray($headers); + $this->assertArrayHasKey('content-type', $headers); + } + + /** + * Test class availability check + */ + public function testSwooleAvailability(): void + { + $classExists = class_exists('Swoole\Coroutine\Http\Client'); + if ($classExists) { + $this->assertNotNull($this->adapter); + } else { + $this->assertNull($this->adapter); + } + } +} diff --git a/tests/ClientTest.php b/tests/ClientTest.php index bc9d78b..59843e3 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -3,9 +3,48 @@ namespace Utopia\Fetch; use PHPUnit\Framework\TestCase; +use Utopia\Fetch\Adapter\Curl; +use Utopia\Fetch\Adapter\Swoole; final class ClientTest extends TestCase { + /** + * Test that Client uses Curl adapter by default + */ + public function testDefaultAdapter(): void + { + $client = new Client(); + $response = $client->fetch('127.0.0.1:8000', Client::METHOD_GET); + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + } + + /** + * Test that Client can use a custom adapter + */ + public function testCustomAdapter(): void + { + $client = new Client(new Curl()); + $response = $client->fetch('127.0.0.1:8000', Client::METHOD_GET); + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + } + + /** + * Test that Client works with Swoole adapter if available + */ + public function testSwooleAdapter(): void + { + if (!class_exists('Swoole\Coroutine\Http\Client')) { + $this->markTestSkipped('Swoole extension is not installed'); + } + + $client = new Client(new Swoole()); + $response = $client->fetch('127.0.0.1:8000', Client::METHOD_GET); + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + } + /** * End to end test for Client::fetch * Uses the PHP inbuilt server to test the Client::fetch method @@ -45,6 +84,7 @@ public function testFetch( } if ($resp->getStatusCode() === 200) { // If the response is OK $respData = $resp->json(); // Convert body to array + $this->assertIsArray($respData); $this->assertSame($respData['method'], $method); // Assert that the method is equal to the response's method if ($method != Client::METHOD_GET) { if (empty($body)) { // if body is empty then response body should be an empty string @@ -63,23 +103,28 @@ public function testFetch( json_encode($respData['query']), // Converting the query to JSON string json_encode($query) // Converting the query to JSON string ); // Assert that the args are equal to the response's args + $this->assertIsString($respData['headers']); $respHeaders = json_decode($respData['headers'], true); // Converting the headers to array + $this->assertIsArray($respHeaders); $host = $respHeaders['Host']; - if (array_key_exists('Content-Type', $respHeaders)) { - $contentType = $respHeaders['Content-Type']; - } else { - $contentType = $respHeaders['content-type']; - } - $contentType = explode(';', $contentType)[0]; $this->assertSame($host, $url); // Assert that the host is equal to the response's host - if (empty($headers)) { - if (empty($body)) { - $this->assertSame($contentType, 'application/x-www-form-urlencoded'); + + // Check content-type only when headers were provided or body was sent + if (!empty($headers) || !empty($body)) { + if (array_key_exists('Content-Type', $respHeaders)) { + $contentType = $respHeaders['Content-Type']; } else { - $this->assertSame($contentType, 'application/json'); + $contentType = $respHeaders['content-type'] ?? null; + } + if ($contentType !== null) { + $this->assertIsString($contentType); + $contentType = explode(';', $contentType)[0]; + if (!empty($headers)) { + $this->assertSame($contentType, $headers['content-type']); // Assert that the content-type is equal to the response's content-type + } else { + $this->assertSame($contentType, 'application/json'); + } } - } else { - $this->assertSame($contentType, $headers['content-type']); // Assert that the content-type is equal to the response's content-type } } else { // If the response is not OK echo "Please configure your PHP inbuilt SERVER"; @@ -100,7 +145,7 @@ public function testSendFile( $client = new Client(); $client->addHeader('Content-type', 'multipart/form-data'); $resp = $client->fetch( - url: 'localhost:8000', + url: '127.0.0.1:8000', method: Client::METHOD_POST, body: [ 'file' => new \CURLFile(strval(realpath($path)), $contentType, $fileName) @@ -113,10 +158,11 @@ public function testSendFile( } if ($resp->getStatusCode() === 200) { // If the response is OK $respData = $resp->json(); // Convert body to array + $this->assertIsArray($respData); if (isset($respData['method'])) { $this->assertSame($respData['method'], Client::METHOD_POST); } // Assert that the method is equal to the response's method - $this->assertSame($respData['url'], 'localhost:8000'); // Assert that the url is equal to the response's url + $this->assertSame($respData['url'], '127.0.0.1:8000'); // Assert that the url is equal to the response's url $this->assertSame( json_encode($respData['query']), // Converting the query to JSON string json_encode([]) // Converting the query to JSON string @@ -129,7 +175,9 @@ public function testSendFile( 'error' => 0 ] ]; + $this->assertIsString($respData['files']); $resp_files = json_decode($respData['files'], true); + $this->assertIsArray($resp_files); $this->assertSame($files['file']['name'], $resp_files['file']['name']); $this->assertSame($files['file']['full_path'], $resp_files['file']['full_path']); $this->assertSame($files['file']['type'], $resp_files['file']['type']); @@ -151,7 +199,7 @@ public function testGetFile( try { $client = new Client(); $resp = $client->fetch( - url: 'localhost:8000/' . $type, + url: '127.0.0.1:8000/' . $type, method: Client::METHOD_GET, body: [], query: [] @@ -184,7 +232,7 @@ public function testRedirect(): void try { $client = new Client(); $resp = $client->fetch( - url: 'localhost:8000/redirect', + url: '127.0.0.1:8000/redirect', method: Client::METHOD_GET, body: [], query: [] @@ -195,6 +243,7 @@ public function testRedirect(): void } if ($resp->getStatusCode() === 200) { // If the response is OK $respData = $resp->json(); // Convert body to array + $this->assertIsArray($respData); $this->assertSame($respData['page'], "redirectedPage"); // Assert that the page is the redirected page } else { // If the response is not OK echo "Please configure your PHP inbuilt SERVER"; @@ -279,11 +328,11 @@ public function dataSet(): array { return [ 'get' => [ - 'localhost:8000', + '127.0.0.1:8000', Client::METHOD_GET ], 'getWithQuery' => [ - 'localhost:8000', + '127.0.0.1:8000', Client::METHOD_GET, [], [], @@ -293,11 +342,11 @@ public function dataSet(): array ], ], 'postNoBody' => [ - 'localhost:8000', + '127.0.0.1:8000', Client::METHOD_POST ], 'postJsonBody' => [ - 'localhost:8000', + '127.0.0.1:8000', Client::METHOD_POST, [ 'name' => 'John Doe', @@ -308,7 +357,7 @@ public function dataSet(): array ], ], 'postSingleLineJsonStringBody' => [ - 'localhost:8000', + '127.0.0.1:8000', Client::METHOD_POST, '{"name": "John Doe","age": 30}', [ @@ -316,7 +365,7 @@ public function dataSet(): array ] ], 'postMultiLineJsonStringBody' => [ - 'localhost:8000', + '127.0.0.1:8000', Client::METHOD_POST, '{ "name": "John Doe", @@ -327,7 +376,7 @@ public function dataSet(): array ] ], 'postFormDataBody' => [ - 'localhost:8000', + '127.0.0.1:8000', Client::METHOD_POST, [ 'name' => 'John Doe', @@ -390,14 +439,14 @@ public function testRetry(): void $this->assertSame(3, $client->getMaxRetries()); $this->assertSame(1000, $client->getRetryDelay()); - $res = $client->fetch('localhost:8000/mock-retry'); + $res = $client->fetch('127.0.0.1:8000/mock-retry'); $this->assertSame(200, $res->getStatusCode()); unlink(__DIR__ . '/state.json'); // Test if we get a 500 error if we go under the server's max retries $client->setMaxRetries(1); - $res = $client->fetch('localhost:8000/mock-retry'); + $res = $client->fetch('127.0.0.1:8000/mock-retry'); $this->assertSame(503, $res->getStatusCode()); unlink(__DIR__ . '/state.json'); @@ -414,7 +463,7 @@ public function testRetryWithDelay(): void $client->setRetryDelay(3000); $now = microtime(true); - $res = $client->fetch('localhost:8000/mock-retry'); + $res = $client->fetch('127.0.0.1:8000/mock-retry'); $this->assertGreaterThan($now + 3.0, microtime(true)); $this->assertSame(200, $res->getStatusCode()); unlink(__DIR__ . '/state.json'); @@ -432,7 +481,7 @@ public function testCustomRetryStatusCodes(): void $client->setRetryStatusCodes([401]); $now = microtime(true); - $res = $client->fetch('localhost:8000/mock-retry-401'); + $res = $client->fetch('127.0.0.1:8000/mock-retry-401'); $this->assertSame(200, $res->getStatusCode()); $this->assertGreaterThan($now + 3.0, microtime(true)); unlink(__DIR__ . '/state.json'); @@ -449,7 +498,7 @@ public function testChunkHandling(): void $lastChunk = null; $response = $client->fetch( - url: 'localhost:8000/chunked', + url: '127.0.0.1:8000/chunked', method: Client::METHOD_GET, chunks: function (Chunk $chunk) use (&$chunks, &$lastChunk) { $chunks[] = $chunk; @@ -483,7 +532,7 @@ public function testChunkHandlingWithJson(): void $chunks = []; $response = $client->fetch( - url: 'localhost:8000/chunked-json', + url: '127.0.0.1:8000/chunked-json', method: Client::METHOD_POST, body: ['test' => 'data'], chunks: function (Chunk $chunk) use (&$chunks) { @@ -517,7 +566,7 @@ public function testChunkHandlingWithError(): void $errorChunk = null; $response = $client->fetch( - url: 'localhost:8000/error', + url: '127.0.0.1:8000/error', method: Client::METHOD_GET, chunks: function (Chunk $chunk) use (&$errorChunk) { if ($errorChunk === null) { @@ -545,12 +594,12 @@ public function testChunkHandlingWithChunkedError(): void $errorMessages = []; $response = $client->fetch( - url: 'localhost:8000/chunked-error', + url: '127.0.0.1:8000/chunked-error', method: Client::METHOD_GET, chunks: function (Chunk $chunk) use (&$chunks, &$errorMessages) { $chunks[] = $chunk; $data = json_decode($chunk->getData(), true); - if ($data && isset($data['error'])) { + if (is_array($data) && isset($data['error'])) { $errorMessages[] = $data['error']; } } @@ -575,4 +624,63 @@ public function testChunkHandlingWithChunkedError(): void $this->assertIsArray($firstChunk); $this->assertSame('username', $firstChunk['field']); } + + /** + * Test that query parameters are appended correctly when URL has trailing '?' + * @return void + */ + public function testQueryWithTrailingQuestionMark(): void + { + $client = new Client(); + $response = $client->fetch( + url: '127.0.0.1:8000?', + method: Client::METHOD_GET, + query: ['foo' => 'bar'] + ); + + $this->assertSame(200, $response->getStatusCode()); + $data = $response->json(); + $this->assertIsArray($data); + $this->assertSame(['foo' => 'bar'], $data['query']); + } + + /** + * Test that query parameters are appended correctly when URL has trailing '&' + * @return void + */ + public function testQueryWithTrailingAmpersand(): void + { + $client = new Client(); + $response = $client->fetch( + url: '127.0.0.1:8000?existing=value&', + method: Client::METHOD_GET, + query: ['foo' => 'bar'] + ); + + $this->assertSame(200, $response->getStatusCode()); + $data = $response->json(); + $this->assertIsArray($data); + $this->assertSame('value', $data['query']['existing']); + $this->assertSame('bar', $data['query']['foo']); + } + + /** + * Test that query parameters are appended correctly with existing query string + * @return void + */ + public function testQueryWithExistingParams(): void + { + $client = new Client(); + $response = $client->fetch( + url: '127.0.0.1:8000?existing=value', + method: Client::METHOD_GET, + query: ['foo' => 'bar'] + ); + + $this->assertSame(200, $response->getStatusCode()); + $data = $response->json(); + $this->assertIsArray($data); + $this->assertSame('value', $data['query']['existing']); + $this->assertSame('bar', $data['query']['foo']); + } } diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 762f377..18a9d9c 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -49,11 +49,11 @@ public function testClassMethods( $this->assertSame($body, $resp->getBody()); // Assert that the body is equal to the response's body $jsonBody = \json_decode($body, true); // Convert JSON string to object $this->assertSame($jsonBody, $resp->json()); // Assert that the JSON body is equal to the response's JSON body - $bin = ""; // Convert string to binary + $bin = []; // Convert string to binary for ($i = 0, $j = strlen($body); $i < $j; $i++) { - $bin .= decbin(ord($body)) . " "; + $bin[] = decbin(ord($body[$i])); } - $this->assertSame($bin, $resp->blob()); // Assert that the blob body is equal to the response's blob body + $this->assertSame(implode(" ", $bin), $resp->blob()); // Assert that the blob body is equal to the response's blob body } /** * Data provider for testClassConstructorAndGetters and testClassMethods diff --git a/tests/router.php b/tests/router.php index e84f145..76feb3b 100644 --- a/tests/router.php +++ b/tests/router.php @@ -23,7 +23,8 @@ function getState(): array throw new \Exception('Failed to read state file'); } - return json_decode($data, true) ?? []; + $decoded = json_decode($data, true); + return is_array($decoded) ? $decoded : []; } return []; } @@ -42,7 +43,7 @@ function setState(array $newState): void $curPageName = substr($_SERVER['REQUEST_URI'], strrpos($_SERVER['REQUEST_URI'], "/") + 1); if ($curPageName == 'redirect') { - header('Location: http://localhost:8000/redirectedPage'); + header('Location: http://127.0.0.1:8000/redirectedPage'); exit; } elseif ($curPageName == 'image') { $filename = __DIR__."/resources/logo.png"; @@ -169,4 +170,5 @@ function setState(array $newState): void 'page' => $curPageName, ]; +header('Content-Type: application/json'); echo json_encode($resp);