From 7a70cb2a0c3ba48ed595cd62bffa43a72bc83d83 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 7 Oct 2026 18:40:39 +0100 Subject: [PATCH 1/2] refactor(tests): build the e2e client on utopia-php/client utopia-php/fetch is archived and was only kept as a dev dependency for this client, which Edge also reuses for its executor E2E suite. The client keeps its public surface (call(), setKey() and the METHOD_* constants) and fetch's request semantics: no Accept header added, http_build_query encoding for GET parameters, JSON or form bodies by content type, up to 5 redirects followed, a 5s connect timeout, lowercased response headers with the last value of a repeated header winning. --- composer.json | 1 - composer.lock | 43 +-------------- tests/e2e/Client.php | 126 +++++++++++++++++++++++++++++++++---------- 3 files changed, 100 insertions(+), 70 deletions(-) diff --git a/composer.json b/composer.json index 0bb5b83..b356494 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,6 @@ }, "require-dev": { "laravel/pint": "1.*", - "utopia-php/fetch": "^1.1.2", "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^13.1", "rector/rector": "^2.4", diff --git a/composer.lock b/composer.lock index 5aff0c8..bd16e73 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": "16d5264fc24fc81330d529646dbad71d", + "content-hash": "7807b8b9fcaa8d2585ef4e0a91dc903a", "packages": [ { "name": "brick/math", @@ -4731,47 +4731,6 @@ } ], "time": "2025-12-08T11:19:18+00:00" - }, - { - "name": "utopia-php/fetch", - "version": "1.1.2", - "source": { - "type": "git", - "url": "https://github.com/utopia-php/fetch.git", - "reference": "64f2b3a789480f1deb102ce684dac4217d8e98d5" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/utopia-php/fetch/zipball/64f2b3a789480f1deb102ce684dac4217d8e98d5", - "reference": "64f2b3a789480f1deb102ce684dac4217d8e98d5", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "require-dev": { - "laravel/pint": "^1.5.0", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^9.5", - "swoole/ide-helper": "^6.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Utopia\\Fetch\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A simple library that provides an interface for making HTTP Requests.", - "support": { - "issues": "https://github.com/utopia-php/fetch/issues", - "source": "https://github.com/utopia-php/fetch/tree/1.1.2" - }, - "abandoned": true, - "time": "2026-04-29T11:19:19+00:00" } ], "aliases": [], diff --git a/tests/e2e/Client.php b/tests/e2e/Client.php index 8ce7b1b..2d9eb53 100644 --- a/tests/e2e/Client.php +++ b/tests/e2e/Client.php @@ -2,11 +2,36 @@ namespace Tests\E2E; -use Utopia\Fetch\Client as FetchClient; use OpenRuntimes\Executor\BodyMultipart; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use Utopia\Client\Adapter\Curl\Client as Curl; +use Utopia\Client\Client as HttpClient; +use Utopia\Psr7\ContentType; +use Utopia\Psr7\Method; +use Utopia\Psr7\Request\Factory as RequestFactory; +use Utopia\Psr7\Stream\Factory as StreamFactory; -class Client extends FetchClient +class Client { + public const string METHOD_GET = Method::GET; + + public const string METHOD_POST = Method::POST; + + public const string METHOD_PUT = Method::PUT; + + public const string METHOD_PATCH = Method::PATCH; + + public const string METHOD_DELETE = Method::DELETE; + + public const string METHOD_HEAD = Method::HEAD; + + public const string METHOD_OPTIONS = Method::OPTIONS; + + private const int MAX_REDIRECTS = 5; + + private const int CONNECT_TIMEOUT_MS = 5000; + /** * @param array $baseHeaders */ @@ -30,62 +55,109 @@ public function setKey(string $key): void */ public function call(string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true, ?callable $callback = null, int $timeout = 60000): array { - $url = $this->endpoint . $path; + $client = new HttpClient(new Curl()) + ->withTimeout($timeout / 1000) + ->withConnectTimeout(self::CONNECT_TIMEOUT_MS / 1000) + ->withFollowRedirects(true, self::MAX_REDIRECTS); - $client = new FetchClient(); - $client->setTimeout($timeout); + $request = $this->request($method, $this->endpoint . $path, $headers, $params); - foreach ($this->baseHeaders as $key => $value) { - $client->addHeader($key, $value); + if ($callback !== null) { + $response = $client->stream($request, function (string $chunk) use ($callback): void { + $callback($chunk); + }); + } else { + $response = $client->sendRequest($request); } - foreach ($headers as $key => $value) { - $client->addHeader($key, $value); - } - - $response = $client->fetch( - url: $url, - method: $method, - body: $method !== FetchClient::METHOD_GET ? $params : [], - query: $method === FetchClient::METHOD_GET ? $params : [], - chunks: $callback ? function ($chunk) use ($callback): void { - $callback($chunk->getData()); - } : null - ); + $responseHeaders = $this->headers($response); $body = null; if ($callback === null) { if ($decode) { - $contentType = $response->getHeaders()['content-type'] ?? ''; + $contentType = $responseHeaders['content-type'] ?? ''; $strpos = strpos($contentType, ';'); $strpos = is_bool($strpos) ? strlen($contentType) : $strpos; $contentType = substr($contentType, 0, $strpos); switch ($contentType) { case 'multipart/form-data': - $boundary = explode('boundary=', $response->getHeaders()['content-type'] ?? '')[1] ?? ''; + $boundary = explode('boundary=', $responseHeaders['content-type'] ?? '')[1] ?? ''; $multipartResponse = new BodyMultipart($boundary); - $multipartResponse->load($response->text()); + $multipartResponse->load((string) $response->getBody()); $body = $multipartResponse->getParts(); break; case 'application/json': - $body = $response->json(); + $body = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR); break; default: - $body = $response->text(); + $body = (string) $response->getBody(); break; } } else { - $body = $response->text(); + $body = (string) $response->getBody(); } } return [ 'headers' => array_merge( - $response->getHeaders(), + $responseHeaders, ['status-code' => $response->getStatusCode()] ), 'body' => $body ]; } + + /** + * Header names are case-insensitive, so the last value sent under any casing wins. + * GET parameters go in the query string, anything else is a JSON or form body + * chosen by the request's content type. + * + * @param array $headers + * @param array $params + */ + private function request(string $method, string $url, array $headers, array $params): RequestInterface + { + $merged = []; + foreach ([...$this->baseHeaders, ...$headers] as $key => $value) { + $merged[strtolower($key)] = $value; + } + + if ($method === self::METHOD_GET && $params !== []) { + $url = rtrim($url, '?&'); + $url .= (str_contains($url, '?') ? '&' : '?') . http_build_query($params); + } + + $request = new RequestFactory()->createRequest($method, $url); + + if ($method !== self::METHOD_GET) { + $body = match ($merged['content-type'] ?? '') { + ContentType::JSON => json_encode($params, JSON_THROW_ON_ERROR), + ContentType::FORM_URLENCODED => http_build_query($params), + default => $params === [] ? '' : throw new \InvalidArgumentException('Unsupported content type for a request body: ' . ($merged['content-type'] ?? '(none)')), + }; + $request = $request->withBody(new StreamFactory()->createStream($body)); + } + + foreach ($merged as $key => $value) { + $request = $request->withHeader($key, $value); + } + + return $request; + } + + /** + * Lowercased names, keeping the last value of a repeated header. + * + * @return array + */ + private function headers(ResponseInterface $response): array + { + $headers = []; + foreach ($response->getHeaders() as $name => $values) { + $headers[strtolower((string) $name)] = (string) end($values); + } + + return $headers; + } } From 715ba6676e2322260fbcf3ecd67c0af3b39545eb Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 7 Oct 2026 18:53:42 +0100 Subject: [PATCH 2/2] fix(tests): send multipart bodies the way fetch did Two scenarios post their params as multipart/form-data. fetch handed the flattened params to curl, which built the form and added the boundary to the content type; the client now builds the same form with the psr7 factory and keeps its boundary-carrying content type. --- tests/e2e/Client.php | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/tests/e2e/Client.php b/tests/e2e/Client.php index 2d9eb53..d8d4c90 100644 --- a/tests/e2e/Client.php +++ b/tests/e2e/Client.php @@ -110,8 +110,9 @@ public function call(string $method, string $path = '', array $headers = [], arr /** * Header names are case-insensitive, so the last value sent under any casing wins. - * GET parameters go in the query string, anything else is a JSON or form body - * chosen by the request's content type. + * GET parameters go in the query string, anything else is a JSON, form or + * multipart body chosen by the request's content type. A multipart body keeps + * the content type the factory sets, since that one carries the boundary. * * @param array $headers * @param array $params @@ -128,9 +129,13 @@ private function request(string $method, string $url, array $headers, array $par $url .= (str_contains($url, '?') ? '&' : '?') . http_build_query($params); } - $request = new RequestFactory()->createRequest($method, $url); + $factory = new RequestFactory(); + $request = $factory->createRequest($method, $url); - if ($method !== self::METHOD_GET) { + if ($method !== self::METHOD_GET && ($merged['content-type'] ?? '') === ContentType::MULTIPART_FORM_DATA) { + unset($merged['content-type']); + $request = $factory->multipart($method, $url, self::flatten($params)); + } elseif ($method !== self::METHOD_GET) { $body = match ($merged['content-type'] ?? '') { ContentType::JSON => json_encode($params, JSON_THROW_ON_ERROR), ContentType::FORM_URLENCODED => http_build_query($params), @@ -146,6 +151,33 @@ private function request(string $method, string $url, array $headers, array $par return $request; } + /** + * Nested parameters become `parent[child]` fields and null an empty field, as + * curl sent them. + * + * @param array $data + * @return array + */ + private static function flatten(array $data, string $prefix = ''): array + { + $output = []; + foreach ($data as $key => $value) { + $name = $prefix === '' ? (string) $key : $prefix . '[' . $key . ']'; + + if (is_array($value)) { + $output += self::flatten($value, $name); + } elseif (is_scalar($value)) { + $output[$name] = $value; + } elseif ($value === null) { + $output[$name] = ''; + } else { + throw new \InvalidArgumentException('Unsupported multipart field value for ' . $name); + } + } + + return $output; + } + /** * Lowercased names, keeping the last value of a repeated header. *